mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-02-04 05:35:44 +00:00
Compare commits
543 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2a5916e09 | ||
|
|
3f52e42661 | ||
|
|
224032d0c2 | ||
|
|
4dece2e58a | ||
|
|
7eb85d4454 | ||
|
|
bd750e6f97 | ||
|
|
ee0d4d29e1 | ||
|
|
10d2ea7c0a | ||
|
|
0ab76d53ec | ||
|
|
3dea0a5f67 | ||
|
|
41c2e04cfa | ||
|
|
d8183feb07 | ||
|
|
e49fe2bca8 | ||
|
|
67664a2816 | ||
|
|
eefbe00aec | ||
|
|
be67d5f120 | ||
|
|
fa1fe694cf | ||
|
|
9f681c0c09 | ||
|
|
18dde2849e | ||
|
|
5678774de0 | ||
|
|
f5370e1384 | ||
|
|
934d7cb104 | ||
|
|
33b201147d | ||
|
|
abe2006e15 | ||
|
|
81b5cb386d | ||
|
|
c713dd6c87 | ||
|
|
eda5c490b7 | ||
|
|
b6bc9e1257 | ||
|
|
123ffd8886 | ||
|
|
9a2c09c6e8 | ||
|
|
5b5fdde9a2 | ||
|
|
0cc50d70da | ||
|
|
c79ecddee4 | ||
|
|
e929edbd53 | ||
|
|
8de7a1ec70 | ||
|
|
0b6ae4d225 | ||
|
|
c69bb8bb2b | ||
|
|
e8dda6f946 | ||
|
|
3daebfc0c1 | ||
|
|
d5fcbb97c3 | ||
|
|
97555e7849 | ||
|
|
7eb799d2a6 | ||
|
|
d06a85a3b6 | ||
|
|
f616eadccf | ||
|
|
b69d6c193b | ||
|
|
d12908778e | ||
|
|
95c42f3266 | ||
|
|
597e6de682 | ||
|
|
406141a4f1 | ||
|
|
0db7fe63b0 | ||
|
|
e5a43ef5b6 | ||
|
|
4d9a9c9ac2 | ||
|
|
3cc1f6b994 | ||
|
|
4322361269 | ||
|
|
44d3651aa8 | ||
|
|
03423f32c8 | ||
|
|
bf8c950a8a | ||
|
|
f16ae734cc | ||
|
|
91196dca39 | ||
|
|
ea36ff5011 | ||
|
|
dad418140b | ||
|
|
d5f8877768 | ||
|
|
95f2dfa8ac | ||
|
|
d60b14c686 | ||
|
|
fabcd7b8f4 | ||
|
|
045880c234 | ||
|
|
d2071102c5 | ||
|
|
864bc8c7f9 | ||
|
|
b63b861b8a | ||
|
|
23effbb3ec | ||
|
|
c0a87c834c | ||
|
|
4d02213260 | ||
|
|
46118964ea | ||
|
|
ca52c588fd | ||
|
|
b9905e10de | ||
|
|
7ea08af177 | ||
|
|
fa59aff6d5 | ||
|
|
c90e3bedb1 | ||
|
|
62cf565a09 | ||
|
|
4503361877 | ||
|
|
f2bc7ef3ed | ||
|
|
f9505e42c4 | ||
|
|
d340df758e | ||
|
|
741ecf0955 | ||
|
|
90ab2e468e | ||
|
|
360b9d7730 | ||
|
|
bb698dbe9d | ||
|
|
5e120a7328 | ||
|
|
206daba741 | ||
|
|
79e86e8b76 | ||
|
|
398c17c75a | ||
|
|
49adc95170 | ||
|
|
2b71bd1178 | ||
|
|
c53938bc0a | ||
|
|
718827d757 | ||
|
|
b4c87e7e10 | ||
|
|
20dd0e9b2d | ||
|
|
397e2d207e | ||
|
|
29f7b6cb74 | ||
|
|
a004b55e8c | ||
|
|
221d8f0697 | ||
|
|
e7a243ab7f | ||
|
|
ce1ffe2070 | ||
|
|
32e2419b67 | ||
|
|
5f7079e526 | ||
|
|
4ffeae11e2 | ||
|
|
7621e31d38 | ||
|
|
c00c751127 | ||
|
|
a09f550b52 | ||
|
|
0aeb1cfa97 | ||
|
|
46da7e45b9 | ||
|
|
83bf448bdd | ||
|
|
5ab15fb812 | ||
|
|
0dc70f3e42 | ||
|
|
5bc86a75bc | ||
|
|
51d3df9282 | ||
|
|
31925ea099 | ||
|
|
a56e6540e8 | ||
|
|
03bccb4cb4 | ||
|
|
bba9b7b6ec | ||
|
|
5b8ea6e4b0 | ||
|
|
91676d802c | ||
|
|
395d343f69 | ||
|
|
201993ff08 | ||
|
|
f9499904f3 | ||
|
|
0a152474a2 | ||
|
|
8dc522e054 | ||
|
|
eb8209d575 | ||
|
|
96b3ac7435 | ||
|
|
42d7113896 | ||
|
|
7f19786053 | ||
|
|
52b121a9f4 | ||
|
|
d191a8223e | ||
|
|
b371b43a86 | ||
|
|
c243f384a5 | ||
|
|
e10f8d759c | ||
|
|
977e5b8cb6 | ||
|
|
b68f95a7e7 | ||
|
|
e30b01de23 | ||
|
|
68f87db33e | ||
|
|
f2bfa462a0 | ||
|
|
adffcf5229 | ||
|
|
a4f37cbcfa | ||
|
|
18d8328a9c | ||
|
|
a466578ebf | ||
|
|
c92f8833e2 | ||
|
|
2f46c58028 | ||
|
|
a1c24e346c | ||
|
|
fb7fb62a0e | ||
|
|
7378ee1a32 | ||
|
|
0bee5b4365 | ||
|
|
47bfd6f539 | ||
|
|
1b92cb7f86 | ||
|
|
2931d47d05 | ||
|
|
7668a54648 | ||
|
|
aa0aaf7c10 | ||
|
|
4b80da96ab | ||
|
|
396fa19d7b | ||
|
|
4cd235a08e | ||
|
|
edd21956c5 | ||
|
|
f842156253 | ||
|
|
ce0ce0f229 | ||
|
|
ae2e9219c3 | ||
|
|
146b374ca8 | ||
|
|
9264676407 | ||
|
|
eb74460fcf | ||
|
|
16ed582052 | ||
|
|
a7ddb81ad1 | ||
|
|
d09f3870b1 | ||
|
|
9a8e249964 | ||
|
|
c63d9cf0b9 | ||
|
|
f6648f80ec | ||
|
|
250bff7ef3 | ||
|
|
fa0b925aa9 | ||
|
|
28fe2949f2 | ||
|
|
d7f46216e9 | ||
|
|
3f0d82a427 | ||
|
|
d2811c9fba | ||
|
|
028e26ef59 | ||
|
|
4086332857 | ||
|
|
1eebfccf14 | ||
|
|
f1127f917d | ||
|
|
6cc8700bb9 | ||
|
|
3cabcd5cf9 | ||
|
|
c5aaeb67b5 | ||
|
|
47fcd54d1c | ||
|
|
5dd5cd8ae1 | ||
|
|
8ab18160a5 | ||
|
|
b70b697f3f | ||
|
|
48eb35e20d | ||
|
|
4fd899133f | ||
|
|
3b134cf8b5 | ||
|
|
86bc538601 | ||
|
|
fb00fbc850 | ||
|
|
309f9fcfa1 | ||
|
|
253aa63980 | ||
|
|
93dea13f20 | ||
|
|
3d3d1718b7 | ||
|
|
90c2a5d51f | ||
|
|
8846a229d8 | ||
|
|
acf0591adc | ||
|
|
95bee47826 | ||
|
|
b701976356 | ||
|
|
2b61941e34 | ||
|
|
1526ffa3af | ||
|
|
bb4227e537 | ||
|
|
778691405d | ||
|
|
cb7d3b91c6 | ||
|
|
fd8cf16336 | ||
|
|
2403cf222f | ||
|
|
20152dd8a6 | ||
|
|
b459207b7a | ||
|
|
8b009509c8 | ||
|
|
c456891a11 | ||
|
|
6b044d8086 | ||
|
|
3162de84ed | ||
|
|
641768240a | ||
|
|
8b7ade4b9c | ||
|
|
c7db9394c8 | ||
|
|
513e63329b | ||
|
|
cfd104385d | ||
|
|
2d9641eecf | ||
|
|
65a78125b2 | ||
|
|
28572ba4d3 | ||
|
|
28a603ca1e | ||
|
|
b9fa303f7f | ||
|
|
756dde90ab | ||
|
|
c2a396167e | ||
|
|
c1fd207723 | ||
|
|
8f1fc0a164 | ||
|
|
538ec3c744 | ||
|
|
6fed13bf12 | ||
|
|
104cc7c900 | ||
|
|
8fa699a92e | ||
|
|
1ab7059830 | ||
|
|
377b2613db | ||
|
|
bc7b0a9bdb | ||
|
|
8665a351db | ||
|
|
dd02bf8b8d | ||
|
|
487c423eef | ||
|
|
87bcfa729c | ||
|
|
0332e8c671 | ||
|
|
7264354ce6 | ||
|
|
bde3315994 | ||
|
|
7ecd08b0d8 | ||
|
|
93feb382ca | ||
|
|
186d9b3798 | ||
|
|
1f3e44819d | ||
|
|
4b942f2f45 | ||
|
|
7214cd7179 | ||
|
|
90c11b5c04 | ||
|
|
4007339d26 | ||
|
|
8bc43443a7 | ||
|
|
a389dc702b | ||
|
|
1b39fa37e0 | ||
|
|
bfa18f72fa | ||
|
|
82c2ec0c43 | ||
|
|
3810d088b5 | ||
|
|
7b34b096fe | ||
|
|
831b0bd2d1 | ||
|
|
5875057282 | ||
|
|
b78df8df2a | ||
|
|
7fa3d08e61 | ||
|
|
549303a34c | ||
|
|
9d2cbae115 | ||
|
|
d1a76922c5 | ||
|
|
648889d2d2 | ||
|
|
984e566fe2 | ||
|
|
145296ee10 | ||
|
|
91b91ca96f | ||
|
|
5ea1e9d6d5 | ||
|
|
78f83fe103 | ||
|
|
1b0ee6a757 | ||
|
|
c185853405 | ||
|
|
f50b8bceb6 | ||
|
|
559a10603a | ||
|
|
defe38daaa | ||
|
|
6acdaf0603 | ||
|
|
e9991fc995 | ||
|
|
7ad14174d7 | ||
|
|
a638387dd4 | ||
|
|
3818d0e607 | ||
|
|
3ce4f8da3a | ||
|
|
9d272a1b19 | ||
|
|
3b9224b4da | ||
|
|
b254152746 | ||
|
|
a2d796476e | ||
|
|
898e744767 | ||
|
|
9ef9c5b3de | ||
|
|
b734eeb252 | ||
|
|
92d69d9053 | ||
|
|
4879c49476 | ||
|
|
e81ea15bb0 | ||
|
|
647f174b53 | ||
|
|
d79e6a7606 | ||
|
|
3fd3916ef9 | ||
|
|
b9b73d44c2 | ||
|
|
afa7c2030c | ||
|
|
f668a7a629 | ||
|
|
958fb43ac2 | ||
|
|
9ef8f592ba | ||
|
|
e6feae6c68 | ||
|
|
9dc5810eaa | ||
|
|
c960d6d96a | ||
|
|
45d95d7b45 | ||
|
|
3d1c32a5b4 | ||
|
|
6a67580c33 | ||
|
|
dbafa0a473 | ||
|
|
d348e73b55 | ||
|
|
3f3c1bd6e3 | ||
|
|
1a3f907c62 | ||
|
|
4f8027c6f7 | ||
|
|
86cd2a976b | ||
|
|
52a935bf09 | ||
|
|
ea7c3c4a5b | ||
|
|
9639d98dc3 | ||
|
|
3bfc16b6a8 | ||
|
|
1e54619e81 | ||
|
|
fc4ac98246 | ||
|
|
534e3ee98f | ||
|
|
9b8b8f020d | ||
|
|
aea7fb8069 | ||
|
|
b933633cc0 | ||
|
|
a822eb521d | ||
|
|
b6bb19538f | ||
|
|
c0a1f86801 | ||
|
|
439d79c677 | ||
|
|
b6ee0f118e | ||
|
|
ed037ca6d7 | ||
|
|
58f6239cb2 | ||
|
|
5e7bfcb591 | ||
|
|
03cd99c43c | ||
|
|
dc1742aca6 | ||
|
|
a23142cc3f | ||
|
|
de47319b00 | ||
|
|
c9a2549018 | ||
|
|
966253f7b6 | ||
|
|
b0ce7fb3cf | ||
|
|
f1f85a6563 | ||
|
|
c10ee5e0a8 | ||
|
|
7e86d3128c | ||
|
|
4f3da0ea2e | ||
|
|
406c7d8c6b | ||
|
|
809f1192f4 | ||
|
|
ff280d27f1 | ||
|
|
c566eb95a2 | ||
|
|
393d80600c | ||
|
|
1e44165d18 | ||
|
|
03f4774810 | ||
|
|
b64e0446a3 | ||
|
|
e51cab2418 | ||
|
|
f533d106c1 | ||
|
|
2557691053 | ||
|
|
5f53f95b5d | ||
|
|
2c71574072 | ||
|
|
45d38494f2 | ||
|
|
5c03d0d14c | ||
|
|
8404294f56 | ||
|
|
7f519bb71f | ||
|
|
7a5995002d | ||
|
|
ba8fe8e830 | ||
|
|
949a5b19cc | ||
|
|
5a68a00f38 | ||
|
|
d52d91852a | ||
|
|
b8b39e1ebd | ||
|
|
e795e63d02 | ||
|
|
27dc58a90f | ||
|
|
d4679353af | ||
|
|
73a53c8829 | ||
|
|
eb51f78f8c | ||
|
|
8667443e64 | ||
|
|
0650472551 | ||
|
|
7653ea683a | ||
|
|
d633d2f424 | ||
|
|
98ec370545 | ||
|
|
51b9579d6e | ||
|
|
af42791fa2 | ||
|
|
f7e9a88575 | ||
|
|
331ac4787e | ||
|
|
4ac2e0aa49 | ||
|
|
0cb10c206a | ||
|
|
5fd031f518 | ||
|
|
48c204a7b0 | ||
|
|
e61fc1c84e | ||
|
|
fb64381af9 | ||
|
|
84954f1919 | ||
|
|
cdabe516eb | ||
|
|
e5ddb2cef9 | ||
|
|
963185b927 | ||
|
|
f7ef6a207e | ||
|
|
8088d14340 | ||
|
|
f710b782a3 | ||
|
|
b13921f9aa | ||
|
|
522191052a | ||
|
|
e9e8844342 | ||
|
|
ccbf7b24cf | ||
|
|
57f1417d4c | ||
|
|
d5318e6e88 | ||
|
|
978e3fc610 | ||
|
|
dcb5eef814 | ||
|
|
518ad838e7 | ||
|
|
ba6ced577e | ||
|
|
138253e728 | ||
|
|
ffd299962b | ||
|
|
354a147155 | ||
|
|
a623017325 | ||
|
|
654bd80abd | ||
|
|
3249591351 | ||
|
|
40f934d7a6 | ||
|
|
53717dcc46 | ||
|
|
f16a00fe2e | ||
|
|
ab1af0ce28 | ||
|
|
be444bbbc4 | ||
|
|
e161872604 | ||
|
|
e21fa015bf | ||
|
|
fb3275d89f | ||
|
|
f42e4ffc71 | ||
|
|
7b15c190d3 | ||
|
|
73c2d27620 | ||
|
|
288db125ce | ||
|
|
0effaae435 | ||
|
|
a71f1dd3c6 | ||
|
|
c08843d587 | ||
|
|
44094cd4f4 | ||
|
|
a4bd9d518c | ||
|
|
a739a6e24a | ||
|
|
955b52d54b | ||
|
|
004293008c | ||
|
|
9f26a0b512 | ||
|
|
b16f95a4ba | ||
|
|
c079531d2e | ||
|
|
773f2dc074 | ||
|
|
64d783179b | ||
|
|
3dafd6c277 | ||
|
|
fb2fdb10b2 | ||
|
|
50dedf84d5 | ||
|
|
d7b96a7dca | ||
|
|
1d1acdf7c8 | ||
|
|
440bc76f08 | ||
|
|
d2a598dbf5 | ||
|
|
931f9dab6f | ||
|
|
10ec2af398 | ||
|
|
46382ef89f | ||
|
|
c4116feda4 | ||
|
|
4c18342cf4 | ||
|
|
92f103c1dd | ||
|
|
8c9374e00a | ||
|
|
870f3d2ef9 | ||
|
|
d75b3596aa | ||
|
|
c388d84f57 | ||
|
|
782f6c9655 | ||
|
|
078d12f2a1 | ||
|
|
271e1aa26a | ||
|
|
c9c11c7ff7 | ||
|
|
75da4667b7 | ||
|
|
a2591f7f77 | ||
|
|
72b4e8bd39 | ||
|
|
740b0bc3b1 | ||
|
|
3e48b15db0 | ||
|
|
f1b8a22cc8 | ||
|
|
02b75fcb68 | ||
|
|
9b2541975f | ||
|
|
d3ff9a5b7c | ||
|
|
75acf7d132 | ||
|
|
d6a9430c20 | ||
|
|
5a89720f59 | ||
|
|
bee7133e81 | ||
|
|
8522f88d66 | ||
|
|
10ecc5c75d | ||
|
|
cdd722c975 | ||
|
|
7fd9b258aa | ||
|
|
5368d398fe | ||
|
|
30cc8c8711 | ||
|
|
f7d1fea2af | ||
|
|
ee5674bb6d | ||
|
|
5e1fa61c55 | ||
|
|
92f968f933 | ||
|
|
9eabd75864 | ||
|
|
df121e68b4 | ||
|
|
be0721fa5d | ||
|
|
d670872d73 | ||
|
|
68eb9162a9 | ||
|
|
a65ce23a10 | ||
|
|
bea9bca891 | ||
|
|
9f2c6fe8cc | ||
|
|
07e4276190 | ||
|
|
3db15c168d | ||
|
|
7e9f66ec61 | ||
|
|
c7ce4ec4e3 | ||
|
|
ad32aed204 | ||
|
|
2df9235516 | ||
|
|
a0efb0b9a4 | ||
|
|
a244741640 | ||
|
|
e3f5360a7f | ||
|
|
c280bae14f | ||
|
|
e99671ba81 | ||
|
|
73338462e5 | ||
|
|
1912e69375 | ||
|
|
60a3789e9c | ||
|
|
a89bc5a5ce | ||
|
|
08f3126f46 | ||
|
|
53e746146b | ||
|
|
1e7cc0d655 | ||
|
|
b37a81b297 | ||
|
|
6aec8118f2 | ||
|
|
756732803f | ||
|
|
a6b8abb9ce | ||
|
|
38feb54237 | ||
|
|
ee828de086 | ||
|
|
bc82638fea | ||
|
|
edb6f58f66 | ||
|
|
23b9a6e7bf | ||
|
|
0b3c6058e2 | ||
|
|
cb6572980e | ||
|
|
78e5bbb9dc | ||
|
|
12b52b3e79 | ||
|
|
e226749388 | ||
|
|
001cd0cdcd | ||
|
|
c3806348a0 | ||
|
|
c6a8d80d45 | ||
|
|
2babacd15a | ||
|
|
456ab29a34 | ||
|
|
1de201841a | ||
|
|
a5586fd4ea | ||
|
|
a14ac78957 | ||
|
|
1193ede1ca | ||
|
|
8ad75a1773 | ||
|
|
9ea1b44e64 | ||
|
|
b92ef7922d | ||
|
|
3d6eff639f | ||
|
|
011e761d7f | ||
|
|
a2fdb687a1 | ||
|
|
ff846e0cc1 | ||
|
|
df0ad0d9f1 | ||
|
|
716d1a6cba | ||
|
|
3e7468177d | ||
|
|
d4113e6715 | ||
|
|
7f7ce06d0e | ||
|
|
7cfea596fd | ||
|
|
7eae5d0f6e | ||
|
|
a390658ad9 | ||
|
|
ecc403cbbc | ||
|
|
e870ca856c |
244
.editorconfig
Normal file
244
.editorconfig
Normal file
@@ -0,0 +1,244 @@
|
||||
# Remove the line below if you want to inherit .editorconfig settings from higher directories
|
||||
root = true
|
||||
|
||||
# C# files
|
||||
[*.cs]
|
||||
|
||||
#### Core EditorConfig Options ####
|
||||
|
||||
# Indentation and spacing
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
tab_width = 4
|
||||
|
||||
# New line preferences
|
||||
end_of_line = crlf
|
||||
insert_final_newline = false
|
||||
|
||||
#### .NET Code Actions ####
|
||||
|
||||
# Type members
|
||||
dotnet_hide_advanced_members = false
|
||||
dotnet_member_insertion_location = with_other_members_of_the_same_kind
|
||||
dotnet_property_generation_behavior = prefer_throwing_properties
|
||||
|
||||
# Symbol search
|
||||
dotnet_search_reference_assemblies = true
|
||||
|
||||
#### .NET Coding Conventions ####
|
||||
|
||||
# Organize usings
|
||||
dotnet_separate_import_directive_groups = false
|
||||
dotnet_sort_system_directives_first = false
|
||||
file_header_template = unset
|
||||
|
||||
# this. and Me. preferences
|
||||
dotnet_style_qualification_for_event = false
|
||||
dotnet_style_qualification_for_field = false
|
||||
dotnet_style_qualification_for_method = false
|
||||
dotnet_style_qualification_for_property = false
|
||||
|
||||
# Language keywords vs BCL types preferences
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true
|
||||
dotnet_style_predefined_type_for_member_access = true
|
||||
|
||||
# Parentheses preferences
|
||||
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
|
||||
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
|
||||
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
|
||||
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
|
||||
|
||||
# Modifier preferences
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members
|
||||
|
||||
# Expression-level preferences
|
||||
dotnet_prefer_system_hash_code = true
|
||||
dotnet_style_coalesce_expression = true
|
||||
dotnet_style_collection_initializer = true
|
||||
dotnet_style_explicit_tuple_names = true
|
||||
dotnet_style_namespace_match_folder = true
|
||||
dotnet_style_null_propagation = true
|
||||
dotnet_style_object_initializer = true
|
||||
dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||
dotnet_style_prefer_auto_properties = true
|
||||
dotnet_style_prefer_collection_expression = when_types_loosely_match
|
||||
dotnet_style_prefer_compound_assignment = true
|
||||
dotnet_style_prefer_conditional_expression_over_assignment = true
|
||||
dotnet_style_prefer_conditional_expression_over_return = true
|
||||
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
|
||||
dotnet_style_prefer_inferred_anonymous_type_member_names = true
|
||||
dotnet_style_prefer_inferred_tuple_names = true
|
||||
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
|
||||
dotnet_style_prefer_simplified_boolean_expressions = true
|
||||
dotnet_style_prefer_simplified_interpolation = true
|
||||
|
||||
# Field preferences
|
||||
dotnet_style_readonly_field = true
|
||||
|
||||
# Parameter preferences
|
||||
dotnet_code_quality_unused_parameters = all
|
||||
|
||||
# Suppression preferences
|
||||
dotnet_remove_unnecessary_suppression_exclusions = none
|
||||
|
||||
# New line preferences
|
||||
dotnet_style_allow_multiple_blank_lines_experimental = true
|
||||
dotnet_style_allow_statement_immediately_after_block_experimental = true
|
||||
|
||||
#### C# Coding Conventions ####
|
||||
|
||||
# var preferences
|
||||
csharp_style_var_elsewhere = false
|
||||
csharp_style_var_for_built_in_types = false
|
||||
csharp_style_var_when_type_is_apparent = false
|
||||
|
||||
# Expression-bodied members
|
||||
csharp_style_expression_bodied_accessors = true
|
||||
csharp_style_expression_bodied_constructors = false
|
||||
csharp_style_expression_bodied_indexers = true
|
||||
csharp_style_expression_bodied_lambdas = true
|
||||
csharp_style_expression_bodied_local_functions = false
|
||||
csharp_style_expression_bodied_methods = false
|
||||
csharp_style_expression_bodied_operators = false
|
||||
csharp_style_expression_bodied_properties = true
|
||||
|
||||
# Pattern matching preferences
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true
|
||||
csharp_style_prefer_extended_property_pattern = true
|
||||
csharp_style_prefer_not_pattern = true
|
||||
csharp_style_prefer_pattern_matching = true
|
||||
csharp_style_prefer_switch_expression = true
|
||||
|
||||
# Null-checking preferences
|
||||
csharp_style_conditional_delegate_call = true
|
||||
|
||||
# Modifier preferences
|
||||
csharp_prefer_static_anonymous_function = true
|
||||
csharp_prefer_static_local_function = true
|
||||
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async
|
||||
csharp_style_prefer_readonly_struct = true
|
||||
csharp_style_prefer_readonly_struct_member = true
|
||||
|
||||
# Code-block preferences
|
||||
csharp_prefer_braces = true
|
||||
csharp_prefer_simple_using_statement = true
|
||||
csharp_prefer_system_threading_lock = true
|
||||
csharp_style_namespace_declarations = block_scoped
|
||||
csharp_style_prefer_method_group_conversion = true
|
||||
csharp_style_prefer_primary_constructors = true
|
||||
csharp_style_prefer_top_level_statements = true
|
||||
|
||||
# Expression-level preferences
|
||||
csharp_prefer_simple_default_expression = true
|
||||
csharp_style_deconstructed_variable_declaration = true
|
||||
csharp_style_implicit_object_creation_when_type_is_apparent = true
|
||||
csharp_style_inlined_variable_declaration = true
|
||||
csharp_style_prefer_index_operator = true
|
||||
csharp_style_prefer_local_over_anonymous_function = true
|
||||
csharp_style_prefer_null_check_over_type_check = true
|
||||
csharp_style_prefer_range_operator = true
|
||||
csharp_style_prefer_tuple_swap = true
|
||||
csharp_style_prefer_utf8_string_literals = true
|
||||
csharp_style_throw_expression = true
|
||||
csharp_style_unused_value_assignment_preference = discard_variable
|
||||
csharp_style_unused_value_expression_statement_preference = discard_variable
|
||||
|
||||
# 'using' directive preferences
|
||||
csharp_using_directive_placement = outside_namespace
|
||||
|
||||
# New line preferences
|
||||
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
|
||||
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true
|
||||
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true
|
||||
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true
|
||||
csharp_style_allow_embedded_statements_on_same_line_experimental = true
|
||||
|
||||
#### C# Formatting Rules ####
|
||||
|
||||
# New line preferences
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_finally = true
|
||||
csharp_new_line_before_members_in_anonymous_types = true
|
||||
csharp_new_line_before_members_in_object_initializers = true
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_between_query_expression_clauses = true
|
||||
|
||||
# Indentation preferences
|
||||
csharp_indent_block_contents = true
|
||||
csharp_indent_braces = false
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_case_contents_when_block = true
|
||||
csharp_indent_labels = no_change
|
||||
csharp_indent_switch_labels = true
|
||||
|
||||
# Space preferences
|
||||
csharp_space_after_cast = false
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_after_comma = true
|
||||
csharp_space_after_dot = false
|
||||
csharp_space_after_keywords_in_control_flow_statements = true
|
||||
csharp_space_after_semicolon_in_for_statement = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_around_declaration_statements = false
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_before_comma = false
|
||||
csharp_space_before_dot = false
|
||||
csharp_space_before_open_square_brackets = false
|
||||
csharp_space_before_semicolon_in_for_statement = false
|
||||
csharp_space_between_empty_square_brackets = false
|
||||
csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_name_and_opening_parenthesis = false
|
||||
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_name_and_open_parenthesis = false
|
||||
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||
csharp_space_between_parentheses = false
|
||||
csharp_space_between_square_brackets = false
|
||||
|
||||
# Wrapping preferences
|
||||
csharp_preserve_single_line_blocks = true
|
||||
csharp_preserve_single_line_statements = true
|
||||
|
||||
#### Naming styles ####
|
||||
|
||||
# Naming rules
|
||||
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
|
||||
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
|
||||
|
||||
# Symbol specifications
|
||||
|
||||
dotnet_naming_symbols.interface.applicable_kinds = interface
|
||||
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.interface.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
|
||||
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.types.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.non_field_members.required_modifiers =
|
||||
|
||||
# Naming styles
|
||||
|
||||
dotnet_naming_style.pascal_case.required_prefix =
|
||||
dotnet_naming_style.pascal_case.required_suffix =
|
||||
dotnet_naming_style.pascal_case.word_separator =
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||
dotnet_naming_style.begins_with_i.required_suffix =
|
||||
dotnet_naming_style.begins_with_i.word_separator =
|
||||
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -341,3 +341,9 @@ Radzen.DocFX/_exported_templates
|
||||
Radzen.DocFX/api/*.yml
|
||||
!Radzen.DocFX/api/index.md
|
||||
Radzen.DocFX/api/.manifest
|
||||
Radzen.Blazor.min.js
|
||||
/.claude
|
||||
/SANKEY_PATTERN_COMPARISON.md
|
||||
*.md
|
||||
/.gitignore
|
||||
/.gitignore
|
||||
|
||||
35
Dockerfile
35
Dockerfile
@@ -1,31 +1,24 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM mono:latest
|
||||
|
||||
ENV DOCFX_VER 2.58.4
|
||||
|
||||
RUN apt-get update && apt-get install unzip wget git -y && wget -q -P /tmp https://github.com/dotnet/docfx/releases/download/v${DOCFX_VER}/docfx.zip && \
|
||||
mkdir -p /opt/docfx && \
|
||||
unzip /tmp/docfx.zip -d /opt/docfx && \
|
||||
echo '#!/bin/bash\nmono /opt/docfx/docfx.exe $@' > /usr/bin/docfx && \
|
||||
chmod +x /usr/bin/docfx && \
|
||||
rm -rf /tmp/*
|
||||
|
||||
COPY Radzen.Blazor /app/Radzen.Blazor
|
||||
COPY Radzen.DocFX /app/DocFX
|
||||
COPY RadzenBlazorDemos /app/RadzenBlazorDemos
|
||||
COPY RadzenBlazorDemos.Host /app/RadzenBlazorDemos.Host
|
||||
WORKDIR /app
|
||||
RUN docfx DocFX/docfx.json
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0
|
||||
|
||||
COPY --from=0 /app/RadzenBlazorDemos.Host /app/RadzenBlazorDemos.Host
|
||||
COPY --from=0 /app/RadzenBlazorDemos /app/RadzenBlazorDemos
|
||||
COPY Radzen.Blazor /app/Radzen.Blazor
|
||||
COPY Radzen.DocFX /app/Radzen.DocFX
|
||||
COPY RadzenBlazorDemos /app/RadzenBlazorDemos
|
||||
COPY RadzenBlazorDemos.Host /app/RadzenBlazorDemos.Host
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN dotnet tool install -g docfx
|
||||
ENV PATH="$PATH:/root/.dotnet/tools"
|
||||
RUN wget https://dot.net/v1/dotnet-install.sh \
|
||||
&& bash dotnet-install.sh --channel 8.0 --runtime dotnet --install-dir /usr/share/dotnet
|
||||
RUN dotnet build -c Release Radzen.Blazor/Radzen.Blazor.csproj -f net8.0
|
||||
RUN docfx Radzen.DocFX/docfx.json
|
||||
|
||||
WORKDIR /app/RadzenBlazorDemos.Host
|
||||
RUN dotnet publish -c Release -o out
|
||||
|
||||
ENV ASPNETCORE_URLS http://*:5000
|
||||
ENV ASPNETCORE_URLS=http://*:5000
|
||||
WORKDIR /app/RadzenBlazorDemos.Host/out
|
||||
|
||||
ENTRYPOINT ["dotnet", "RadzenBlazorDemos.Host.dll"]
|
||||
|
||||
171
Radzen.Blazor.Tests/AIChatTests.cs
Normal file
171
Radzen.Blazor.Tests/AIChatTests.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using Bunit;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Radzen;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
{
|
||||
public class AIChatTests
|
||||
{
|
||||
private void RegisterChatService(TestContext ctx)
|
||||
{
|
||||
// Register a dummy HttpClient and default options for AIChatService
|
||||
ctx.Services.AddSingleton(new HttpClient());
|
||||
ctx.Services.AddScoped<IAIChatService, AIChatService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldRenderWithDefaultProperties()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>();
|
||||
Assert.Contains("Type your message...", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldRenderWithCustomTitle()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
|
||||
.Add(p => p.Title, "Custom Chat"));
|
||||
Assert.Contains("Custom Chat", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldRenderWithCustomPlaceholder()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
|
||||
.Add(p => p.Placeholder, "Enter your message here..."));
|
||||
Assert.Contains("Enter your message here...", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldRenderWithCustomEmptyMessage()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
|
||||
.Add(p => p.EmptyMessage, "No messages yet"));
|
||||
Assert.Contains("No messages yet", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldShowClearButtonByDefault()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>();
|
||||
Assert.Contains("rz-chat-header-clear", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldHideClearButtonWhenShowClearButtonIsFalse()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
|
||||
.Add(p => p.ShowClearButton, false));
|
||||
Assert.DoesNotContain("clear_all", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldBeDisabledWhenDisabledIsTrue()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
|
||||
.Add(p => p.Disabled, true));
|
||||
Assert.Contains("disabled", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldBeReadOnlyWhenReadOnlyIsTrue()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
|
||||
.Add(p => p.ReadOnly, true));
|
||||
Assert.Contains("readonly", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldHaveCorrectCssClass()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>();
|
||||
Assert.Contains("rz-chat", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatMessage_ShouldHaveCorrectProperties()
|
||||
{
|
||||
// Arrange
|
||||
var message = new ChatMessage
|
||||
{
|
||||
Content = "Test message",
|
||||
IsUser = true,
|
||||
Timestamp = DateTime.Now
|
||||
};
|
||||
// Assert
|
||||
Assert.NotEmpty(message.Id);
|
||||
Assert.Equal("Test message", message.Content);
|
||||
Assert.True(message.IsUser);
|
||||
Assert.False(message.IsStreaming);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_AddMessage_ShouldAddMessageToList()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>();
|
||||
// Act
|
||||
component.Instance.AddMessage("Test message", true);
|
||||
// Assert
|
||||
var messages = component.Instance.GetMessages();
|
||||
Assert.Single(messages);
|
||||
Assert.Equal("Test message", messages[0].Content);
|
||||
Assert.True(messages[0].IsUser);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ClearChat_ShouldRemoveAllMessages()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>();
|
||||
component.Instance.AddMessage("Test message 1", true);
|
||||
component.Instance.AddMessage("Test message 2", false);
|
||||
// Act
|
||||
component.InvokeAsync(async () => await component.Instance.ClearChat()).Wait();
|
||||
// Assert
|
||||
Assert.Empty(component.Instance.GetMessages());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenAIChat_ShouldLimitMessagesToMaxMessages()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
RegisterChatService(ctx);
|
||||
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters.Add(p => p.MaxMessages, 3));
|
||||
component.Instance.AddMessage("Message 1", true);
|
||||
component.Instance.AddMessage("Message 2", false);
|
||||
component.Instance.AddMessage("Message 3", true);
|
||||
component.Instance.AddMessage("Message 4", false);
|
||||
// Assert
|
||||
var messages = component.Instance.GetMessages();
|
||||
Assert.Equal(3, messages.Count);
|
||||
Assert.Equal("Message 2", messages[0].Content);
|
||||
Assert.Equal("Message 3", messages[1].Content);
|
||||
Assert.Equal("Message 4", messages[2].Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,12 @@ using Microsoft.AspNetCore.Components.Rendering;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using static System.Reflection.Metadata.BlobBuilder;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
{
|
||||
@@ -838,5 +842,152 @@ namespace Radzen.Blazor.Tests
|
||||
Assert.True(newArgs.Skip == 20);
|
||||
Assert.True(newArgs.Top == 20);
|
||||
}
|
||||
|
||||
// Filter tests
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for testing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tests that involves filtering on <see cref="RadzenDataGrid{TItem}"/> requires the generic parameter to be a specific type.
|
||||
/// They do not work using <c>object</c> or <c>dynamic</c>.
|
||||
/// </remarks>
|
||||
/// <param name="Name"></param>
|
||||
/// <param name="Roles"></param>
|
||||
private sealed record User(string Name, IEnumerable<Role> Roles);
|
||||
/// <summary>
|
||||
/// Utility class for testing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tests that involves filtering on <see cref="RadzenDataGrid{TItem}"/> requires the generic parameter to be a specific type.
|
||||
/// They do not work using <c>object</c> or <c>dynamic</c>.
|
||||
/// </remarks>
|
||||
/// <param name="Id"></param>
|
||||
/// <param name="Description"></param>
|
||||
private sealed record Role(int Id, string Description);
|
||||
|
||||
[Fact]
|
||||
public async Task DataGrid_FilterBySubProperties_ReturnsDataFiltered()
|
||||
{
|
||||
// Arrange
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
Role admin = new(0, "Admin");
|
||||
Role guest = new(1, "Guest");
|
||||
User moe = new("Moe", [admin]);
|
||||
User tom = new("Tom", [admin, guest]);
|
||||
User sam = new("Sam", [guest]);
|
||||
|
||||
User[] data = [moe, tom, sam];
|
||||
|
||||
var component = ctx.RenderComponent<RadzenDataGrid<User>>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Data, data);
|
||||
parameters.Add(p => p.AllowFiltering, true);
|
||||
parameters.Add(p => p.FilterMode, FilterMode.CheckBoxList);
|
||||
parameters.Add(p => p.Columns, builder =>
|
||||
{
|
||||
builder.OpenComponent(0, typeof(RadzenDataGridColumn<User>));
|
||||
builder.AddAttribute(1, "Property", "Name");
|
||||
builder.AddAttribute(2, "Title", "User");
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent(0, typeof(RadzenDataGridColumn<User>));
|
||||
builder.AddAttribute(1, "Property", "Roles");
|
||||
builder.AddAttribute(2, "FilterProperty", "Id");
|
||||
builder.AddAttribute(3, "Type", typeof(IEnumerable<Role>));
|
||||
builder.AddAttribute(4, "Title", "Roles");
|
||||
builder.CloseComponent();
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
await component.InvokeAsync(() => component
|
||||
.Instance
|
||||
.ColumnsCollection
|
||||
.First(c => c.Property == "Roles")
|
||||
.SetFilterValueAsync(new[] { 1 })
|
||||
);
|
||||
|
||||
component.Render();
|
||||
|
||||
var filteredData = await component.InvokeAsync(component.Instance.View.ToArray);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain(moe, filteredData);
|
||||
Assert.Contains(sam, filteredData);
|
||||
Assert.Contains(tom, filteredData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataGrid_LoadFilterSettingsFromJson_ReturnsDataFiltered()
|
||||
{
|
||||
// Arrange
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
Role admin = new(0, "Admin");
|
||||
Role guest = new(1, "Guest");
|
||||
User moe = new("Moe", [admin]);
|
||||
User tom = new("Tom", [admin, guest]);
|
||||
User sam = new("Sam", [guest]);
|
||||
|
||||
User[] data = [moe, tom, sam];
|
||||
|
||||
string settings = string.Empty;
|
||||
|
||||
var component = ctx.RenderComponent<RadzenDataGrid<User>>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Data, data);
|
||||
parameters.Add(p => p.AllowFiltering, true);
|
||||
parameters.Add(p => p.FilterMode, FilterMode.CheckBoxList);
|
||||
parameters.Add(p => p.LoadSettings, OnLoadSettings);
|
||||
parameters.Add(p => p.SettingsChanged, OnSettingsChanged);
|
||||
parameters.Add(p => p.Columns, builder =>
|
||||
{
|
||||
builder.OpenComponent(0, typeof(RadzenDataGridColumn<User>));
|
||||
builder.AddAttribute(1, "Property", "Name");
|
||||
builder.AddAttribute(2, "Title", "User");
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent(0, typeof(RadzenDataGridColumn<User>));
|
||||
builder.AddAttribute(1, "Property", "Roles");
|
||||
builder.AddAttribute(2, "FilterProperty", "Id");
|
||||
builder.AddAttribute(3, "Type", typeof(IEnumerable<Role>));
|
||||
builder.AddAttribute(4, "Title", "Roles");
|
||||
builder.CloseComponent();
|
||||
});
|
||||
});
|
||||
|
||||
void OnSettingsChanged(DataGridSettings args)
|
||||
{
|
||||
settings = JsonSerializer.Serialize(args);
|
||||
}
|
||||
|
||||
void OnLoadSettings(DataGridLoadSettingsEventArgs args)
|
||||
{
|
||||
if (string.IsNullOrEmpty(settings)) return;
|
||||
|
||||
args.Settings = JsonSerializer.Deserialize<DataGridSettings>(settings);
|
||||
}
|
||||
|
||||
// Act
|
||||
await component.InvokeAsync(() => component
|
||||
.Instance
|
||||
.ColumnsCollection
|
||||
.First(c => c.Property == "Roles")
|
||||
.SetFilterValueAsync(new[] { 1 })
|
||||
);
|
||||
|
||||
component.Render();
|
||||
|
||||
var filteredData = await component.InvokeAsync(component.Instance.View.ToArray);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain(moe, filteredData);
|
||||
Assert.Contains(sam, filteredData);
|
||||
Assert.Contains(tom, filteredData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,34 @@ namespace Radzen.Blazor.Tests
|
||||
Assert.Contains(@$"tabindex=""{value}""", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatePicker_Renders_EmptyCssClass_WhenValueIsEmpty()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenDatePicker<DateTime>>();
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.Add(p => p.Value, null));
|
||||
|
||||
Assert.Contains(@$"rz-state-empty", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatePicker_DoesNotRender_EmptyCssClass_WhenValueIsNotEmpty()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenDatePicker<DateTime>>();
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.Add(p => p.Value, DateTime.Now));
|
||||
|
||||
Assert.DoesNotContain(@$"rz-state-empty", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatePicker_Renders_DisabledParameter()
|
||||
{
|
||||
|
||||
341
Radzen.Blazor.Tests/DialogServiceTests.cs
Normal file
341
Radzen.Blazor.Tests/DialogServiceTests.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
{
|
||||
public class DialogServiceTests
|
||||
{
|
||||
public class OpenDialogTests
|
||||
{
|
||||
[Fact(DisplayName = "DialogOptions default values are set correctly")]
|
||||
public void DialogOptions_DefaultValues_AreSetCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DialogOptions();
|
||||
var dialogService = new DialogService(null, null);
|
||||
|
||||
// Act
|
||||
dialogService.OpenDialog<DialogServiceTests>("Test", [], options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("600px", options.Width);
|
||||
Assert.Equal("", options.Left);
|
||||
Assert.Equal("", options.Top);
|
||||
Assert.Equal("", options.Bottom);
|
||||
Assert.Equal("", options.Height);
|
||||
Assert.Equal("", options.Style);
|
||||
Assert.Equal("", options.CssClass);
|
||||
Assert.Equal("", options.WrapperCssClass);
|
||||
Assert.Equal("", options.ContentCssClass);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DialogOptions values are retained after OpenDialog call")]
|
||||
public void DialogOptions_Values_AreRetained_AfterOpenDialogCall()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DialogOptions
|
||||
{
|
||||
Width = "800px",
|
||||
Left = "10px",
|
||||
Top = "20px",
|
||||
Bottom = "30px",
|
||||
Height = "400px",
|
||||
Style = "background-color: red;",
|
||||
CssClass = "custom-class",
|
||||
WrapperCssClass = "wrapper-class",
|
||||
ContentCssClass = "content-class"
|
||||
};
|
||||
var dialogService = new DialogService(null, null);
|
||||
|
||||
// Act
|
||||
dialogService.OpenDialog<DialogServiceTests>("Test", [], options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("800px", options.Width);
|
||||
Assert.Equal("10px", options.Left);
|
||||
Assert.Equal("20px", options.Top);
|
||||
Assert.Equal("30px", options.Bottom);
|
||||
Assert.Equal("400px", options.Height);
|
||||
Assert.Equal("background-color: red;", options.Style);
|
||||
Assert.Equal("custom-class", options.CssClass);
|
||||
Assert.Equal("wrapper-class", options.WrapperCssClass);
|
||||
Assert.Equal("content-class", options.ContentCssClass);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DialogOptions is null and default values are set correctly")]
|
||||
public void DialogOptions_IsNull_DefaultValues_AreSetCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
DialogOptions resultingOptions = null;
|
||||
var dialogService = new DialogService(null, null);
|
||||
dialogService.OnOpen += (title, type, parameters, options) => resultingOptions = options;
|
||||
|
||||
// Act
|
||||
dialogService.OpenDialog<DialogServiceTests>("Test", [], null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resultingOptions);
|
||||
Assert.Equal("600px", resultingOptions.Width);
|
||||
Assert.Equal("", resultingOptions.Left);
|
||||
Assert.Equal("", resultingOptions.Top);
|
||||
Assert.Equal("", resultingOptions.Bottom);
|
||||
Assert.Equal("", resultingOptions.Height);
|
||||
Assert.Equal("", resultingOptions.Style);
|
||||
Assert.Equal("", resultingOptions.CssClass);
|
||||
Assert.Equal("", resultingOptions.WrapperCssClass);
|
||||
Assert.Equal("", resultingOptions.ContentCssClass);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Open with dynamic component type reflective calls are resolved without exception")]
|
||||
public void Open_DynamicComponentType_Reflective_Calls_Resolve()
|
||||
{
|
||||
// Arrange
|
||||
string resultingTitle = null;
|
||||
Type resultingType = null;
|
||||
var dialogService = new DialogService(null, null);
|
||||
dialogService.OnOpen += (title, type, _, _) =>
|
||||
{
|
||||
resultingTitle = title;
|
||||
resultingType = type;
|
||||
};
|
||||
|
||||
dialogService.Open("Dynamic Open", typeof(RadzenButton), []);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Dynamic Open", resultingTitle);
|
||||
Assert.Equal(typeof(RadzenButton), resultingType);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "OpenAsync with dynamic component type reflective calls are resolved without exception")]
|
||||
public async Task OpenAsync_DynamicComponentType_Reflective_Calls_Resolve()
|
||||
{
|
||||
// Arrange
|
||||
string resultingTitle = null;
|
||||
Type resultingType = null;
|
||||
var dialogService = new DialogService(null, null);
|
||||
dialogService.OnOpen += (title, type, _, _) =>
|
||||
{
|
||||
resultingTitle = title;
|
||||
resultingType = type;
|
||||
};
|
||||
|
||||
var openTask = dialogService.OpenAsync("Dynamic Open", typeof(RadzenButton), []);
|
||||
dialogService.Close();
|
||||
await openTask;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Dynamic Open", resultingTitle);
|
||||
Assert.Equal(typeof(RadzenButton), resultingType);
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfirmTests
|
||||
{
|
||||
[Fact(DisplayName = "ConfirmOptions is null and default values are set correctly")]
|
||||
public async Task ConfirmOptions_IsNull_AreSetCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var dialogService = new DialogService(null, null);
|
||||
ConfirmOptions resultingOptions = null;
|
||||
dialogService.OnOpen += (title, type, parameters, options) => resultingOptions = options as ConfirmOptions;
|
||||
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await dialogService.Confirm(cancellationToken: cancellationTokenSource.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// this is expected
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resultingOptions);
|
||||
Assert.Equal("Ok", resultingOptions.OkButtonText);
|
||||
Assert.Equal("Cancel", resultingOptions.CancelButtonText);
|
||||
Assert.Equal("600px", resultingOptions.Width);
|
||||
Assert.Equal("", resultingOptions.Style);
|
||||
Assert.Equal("rz-dialog-confirm", resultingOptions.CssClass);
|
||||
Assert.Equal("rz-dialog-wrapper", resultingOptions.WrapperCssClass);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ConfirmOptions default values are set correctly")]
|
||||
public async Task ConfirmOptions_DefaultValues_AreSetCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var dialogService = new DialogService(null, null);
|
||||
ConfirmOptions resultingOptions = null;
|
||||
dialogService.OnOpen += (title, type, parameters, options) => resultingOptions = options as ConfirmOptions;
|
||||
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await dialogService.Confirm(options: new(), cancellationToken: cancellationTokenSource.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// this is expected
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resultingOptions);
|
||||
Assert.Equal("Ok", resultingOptions.OkButtonText);
|
||||
Assert.Equal("Cancel", resultingOptions.CancelButtonText);
|
||||
Assert.Equal("600px", resultingOptions.Width);
|
||||
Assert.Equal("", resultingOptions.Style);
|
||||
Assert.Equal("rz-dialog-confirm", resultingOptions.CssClass);
|
||||
Assert.Equal("rz-dialog-wrapper", resultingOptions.WrapperCssClass);
|
||||
}
|
||||
[Fact(DisplayName = "ConfirmOptions values are retained after Confirm call")]
|
||||
public async Task Confirm_ProvidedValues_AreRetained()
|
||||
{
|
||||
// Arrange
|
||||
var dialogService = new DialogService(null, null);
|
||||
var options = new ConfirmOptions
|
||||
{
|
||||
OkButtonText = "XXX",
|
||||
CancelButtonText = "YYY",
|
||||
Width = "800px",
|
||||
Style = "background-color: red;",
|
||||
CssClass = "custom-class",
|
||||
WrapperCssClass = "wrapper-class"
|
||||
};
|
||||
ConfirmOptions resultingOptions = null;
|
||||
dialogService.OnOpen += (title, type, parameters, options) => resultingOptions = options as ConfirmOptions;
|
||||
|
||||
// We break out of the dialog immediately, but the options should still be set
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await dialogService.Confirm("Confirm?", "Confirm", options, cancellationToken: cancellationTokenSource.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// this is expected
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resultingOptions);
|
||||
Assert.Equal("XXX", resultingOptions.OkButtonText);
|
||||
Assert.Equal("YYY", resultingOptions.CancelButtonText);
|
||||
Assert.Equal("800px", resultingOptions.Width);
|
||||
Assert.Equal("background-color: red;", resultingOptions.Style);
|
||||
Assert.Equal("rz-dialog-confirm custom-class", resultingOptions.CssClass);
|
||||
Assert.Equal("rz-dialog-wrapper wrapper-class", resultingOptions.WrapperCssClass);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class AlertTests
|
||||
{
|
||||
[Fact(DisplayName = "AlertOptions is null and default values are set correctly")]
|
||||
public async Task AlertOptions_IsNull_AreSetCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var dialogService = new DialogService(null, null);
|
||||
AlertOptions resultingOptions = null;
|
||||
dialogService.OnOpen += (title, type, parameters, options) => resultingOptions = options as AlertOptions;
|
||||
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await dialogService.Alert(cancellationToken: cancellationTokenSource.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// this is expected
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resultingOptions);
|
||||
Assert.Equal("Ok", resultingOptions.OkButtonText);
|
||||
Assert.Equal("600px", resultingOptions.Width);
|
||||
Assert.Equal("", resultingOptions.Style);
|
||||
Assert.Equal("rz-dialog-alert", resultingOptions.CssClass);
|
||||
Assert.Equal("rz-dialog-wrapper", resultingOptions.WrapperCssClass);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "AlertOptions default values are set correctly")]
|
||||
public async Task AlertOptions_DefaultValues_AreSetCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var dialogService = new DialogService(null, null);
|
||||
AlertOptions resultingOptions = null;
|
||||
dialogService.OnOpen += (title, type, parameters, options) => resultingOptions = options as AlertOptions;
|
||||
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await dialogService.Alert(options: new(), cancellationToken: cancellationTokenSource.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// this is expected
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resultingOptions);
|
||||
Assert.Equal("Ok", resultingOptions.OkButtonText);
|
||||
Assert.Equal("600px", resultingOptions.Width);
|
||||
Assert.Equal("", resultingOptions.Style);
|
||||
Assert.Equal("rz-dialog-alert", resultingOptions.CssClass);
|
||||
Assert.Equal("rz-dialog-wrapper", resultingOptions.WrapperCssClass);
|
||||
}
|
||||
[Fact(DisplayName = "AlertOptions values are retained after Alert call")]
|
||||
public async Task Alert_ProvidedValues_AreRetained()
|
||||
{
|
||||
// Arrange
|
||||
var dialogService = new DialogService(null, null);
|
||||
var options = new AlertOptions
|
||||
{
|
||||
OkButtonText = "XXX",
|
||||
Width = "800px",
|
||||
Style = "background-color: red;",
|
||||
CssClass = "custom-class",
|
||||
WrapperCssClass = "wrapper-class"
|
||||
};
|
||||
AlertOptions resultingOptions = null;
|
||||
dialogService.OnOpen += (title, type, parameters, options) => resultingOptions = options as AlertOptions;
|
||||
|
||||
// We break out of the dialog immediately, but the options should still be set
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await dialogService.Alert("Alert?", "Alert", options, cancellationToken: cancellationTokenSource.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// this is expected
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resultingOptions);
|
||||
Assert.Equal("XXX", resultingOptions.OkButtonText);
|
||||
Assert.Equal("800px", resultingOptions.Width);
|
||||
Assert.Equal("background-color: red;", resultingOptions.Style);
|
||||
Assert.Equal("rz-dialog-alert custom-class", resultingOptions.CssClass);
|
||||
Assert.Equal("rz-dialog-wrapper wrapper-class", resultingOptions.WrapperCssClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public class DollarsTypeConverter : TypeConverter
|
||||
return new Dollars(d);
|
||||
|
||||
if (value is string s)
|
||||
return decimal.TryParse(s, out var val) ? new Dollars(val) : null;
|
||||
return decimal.TryParse(s, culture, out var val) ? new Dollars(val) : null;
|
||||
|
||||
return base.ConvertFrom(context, culture, value);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
@@ -14,18 +16,20 @@ namespace Radzen.Blazor.Tests
|
||||
{
|
||||
public string Text { get; set; }
|
||||
public int Id { get; set; }
|
||||
public bool Disabled { get; set; }
|
||||
}
|
||||
|
||||
private static IRenderedComponent<RadzenDropDown<T>> DropDown<T>(TestContext ctx, Action<ComponentParameterCollectionBuilder<RadzenDropDown<T>>> configure = null)
|
||||
{
|
||||
var data = new [] {
|
||||
var data = new[] {
|
||||
new DataItem { Text = "Item 1", Id = 1 },
|
||||
new DataItem { Text = "Item 2", Id = 2 },
|
||||
};
|
||||
|
||||
var component = ctx.RenderComponent<RadzenDropDown<T>>();
|
||||
|
||||
component.SetParametersAndRender(parameters => {
|
||||
component.SetParametersAndRender(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Data, data);
|
||||
parameters.Add(p => p.TextProperty, nameof(DataItem.Text));
|
||||
|
||||
@@ -42,6 +46,7 @@ namespace Radzen.Blazor.Tests
|
||||
return component;
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Dropdown_SelectItem_Method_Should_Not_Throw()
|
||||
{
|
||||
@@ -100,7 +105,8 @@ namespace Radzen.Blazor.Tests
|
||||
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var component = DropDown<string>(ctx, parameters => {
|
||||
var component = DropDown<string>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Text));
|
||||
});
|
||||
|
||||
@@ -123,7 +129,8 @@ namespace Radzen.Blazor.Tests
|
||||
|
||||
List<DataItem> boundCollection = [new() { Text = "Item 2" }];
|
||||
|
||||
var component = DropDown<string>(ctx, parameters => {
|
||||
var component = DropDown<List<DataItem>>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.ItemComparer, new DataItemComparer());
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
parameters.Add(p => p.Value, boundCollection);
|
||||
@@ -150,7 +157,8 @@ namespace Radzen.Blazor.Tests
|
||||
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var component = DropDown<string>(ctx, parameters => {
|
||||
var component = DropDown<string>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Text));
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
});
|
||||
@@ -275,6 +283,365 @@ namespace Radzen.Blazor.Tests
|
||||
Assert.Collection(selectedItems, item => Assert.Contains("value: Item 1", item.Text()), item => Assert.Contains("value: Item 2", item.Text()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, true, false, true, "false")]
|
||||
[InlineData(true, false, true, false, "true")]
|
||||
[InlineData(true, false, false, false, "false")]
|
||||
[InlineData(true, false, false, true, "true")]
|
||||
[InlineData(false, false, false, true, "false")]
|
||||
public void DropDown_AllSelectedFalseIfListIsAllDisabled(bool item1Selected, bool item1Disabled, bool item2Selected, bool item2Disabled, string expectedAriaCheckedValue)
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var data = new[] {
|
||||
new DataItem { Text = "Item 1", Id = 1, Disabled = item1Disabled },
|
||||
new DataItem { Text = "Item 2", Id = 2, Disabled = item2Disabled },
|
||||
};
|
||||
|
||||
List<int> selectedValues = [];
|
||||
if (item1Selected)
|
||||
{
|
||||
selectedValues.Add(data[0].Id);
|
||||
}
|
||||
if (item2Selected)
|
||||
{
|
||||
selectedValues.Add(data[1].Id);
|
||||
}
|
||||
|
||||
var component = ctx.RenderComponent<RadzenDropDown<List<int>>>(parameters => parameters
|
||||
.Add(p => p.Data, data)
|
||||
.Add(p => p.Value, selectedValues)
|
||||
.Add(p => p.Multiple, true)
|
||||
.Add(p => p.AllowSelectAll, true)
|
||||
.Add(p => p.TextProperty, nameof(DataItem.Text))
|
||||
.Add(p => p.DisabledProperty, nameof(DataItem.Disabled))
|
||||
.Add(p => p.ValueProperty, nameof(DataItem.Id)));
|
||||
|
||||
Assert.NotNull(component);
|
||||
var highlightedItems = component.FindAll(".rz-state-highlight");
|
||||
Assert.Equal(selectedValues.Count, highlightedItems.Count);
|
||||
|
||||
|
||||
var selectAllCheckBox = component.Find(".rz-multiselect-header input[type='checkbox']");
|
||||
|
||||
Assert.Equal(expectedAriaCheckedValue, selectAllCheckBox.GetAttribute("aria-checked"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropDown_ReferenceGenericCollectionAssignment_HashSet_ReferencesInstance()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var originalHashSet = new HashSet<int>();
|
||||
var capturedValue = (HashSet<int>)null;
|
||||
|
||||
var component = DropDownWithReferenceCollection<HashSet<int>>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
parameters.Add(p => p.Value, originalHashSet);
|
||||
parameters.Add(p => p.ValueChanged, EventCallback.Factory.Create<HashSet<int>>(this, value => capturedValue = value));
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Id));
|
||||
});
|
||||
|
||||
var items = component.FindAll(".rz-multiselect-item");
|
||||
|
||||
// Select first item
|
||||
items[0].Click();
|
||||
component.Render();
|
||||
|
||||
// Verify the same HashSet instance is Referenced
|
||||
Assert.Same(originalHashSet, capturedValue);
|
||||
|
||||
// Verify the item was added correctly
|
||||
Assert.Single(originalHashSet);
|
||||
Assert.Contains(1, originalHashSet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropDown_ReferenceGenericCollectionAssignment_HashSet_MultipleSelections()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var originalHashSet = new HashSet<int> { 2 }; // Pre-populate with Item 2
|
||||
var capturedValues = new List<HashSet<int>>();
|
||||
|
||||
var component = DropDownWithReferenceCollection<HashSet<int>>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
parameters.Add(p => p.Value, originalHashSet);
|
||||
parameters.Add(p => p.ValueChanged, EventCallback.Factory.Create<HashSet<int>>(this, value => capturedValues.Add(value)));
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Id));
|
||||
});
|
||||
|
||||
var items = component.FindAll(".rz-multiselect-item");
|
||||
|
||||
// Select first item (should add to existing collection)
|
||||
items[0].Click();
|
||||
component.Render();
|
||||
|
||||
// Verify the same HashSet instance is Referenced
|
||||
Assert.Single(capturedValues);
|
||||
Assert.Same(originalHashSet, capturedValues[0]);
|
||||
|
||||
// Verify both items are now in the collection
|
||||
Assert.Equal(2, originalHashSet.Count);
|
||||
Assert.Contains(1, originalHashSet);
|
||||
Assert.Contains(2, originalHashSet);
|
||||
|
||||
// Deselect second item (should remove from collection)
|
||||
items = component.FindAll(".rz-multiselect-item"); // Re-find items after render
|
||||
items[1].Click();
|
||||
component.Render();
|
||||
|
||||
// Verify the same HashSet instance is still Referenced
|
||||
Assert.Equal(2, capturedValues.Count);
|
||||
Assert.Same(originalHashSet, capturedValues[1]);
|
||||
|
||||
// Verify only first item remains
|
||||
Assert.Single(originalHashSet);
|
||||
Assert.Contains(1, originalHashSet);
|
||||
Assert.DoesNotContain(2, originalHashSet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropDown_ReferenceGenericCollectionAssignment_SortedSet_ReferencesInstance()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var originalSortedSet = new SortedSet<int>();
|
||||
var capturedValue = (SortedSet<int>)null;
|
||||
|
||||
var component = DropDownWithReferenceCollection<SortedSet<int>>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
parameters.Add(p => p.Value, originalSortedSet);
|
||||
parameters.Add(p => p.ValueChanged, EventCallback.Factory.Create<SortedSet<int>>(this, value => capturedValue = value));
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Id));
|
||||
});
|
||||
|
||||
var items = component.FindAll(".rz-multiselect-item");
|
||||
|
||||
// Select both items
|
||||
items[0].Click();
|
||||
component.Render();
|
||||
items = component.FindAll(".rz-multiselect-item"); // Re-find items after first click
|
||||
items[1].Click();
|
||||
component.Render();
|
||||
|
||||
// Verify the same SortedSet instance is Referenced
|
||||
Assert.Same(originalSortedSet, capturedValue);
|
||||
|
||||
// Verify items are sorted correctly
|
||||
Assert.Equal(2, originalSortedSet.Count);
|
||||
var sortedItems = originalSortedSet.ToList();
|
||||
Assert.Equal(1, sortedItems[0]);
|
||||
Assert.Equal(2, sortedItems[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropDown_ReferenceGenericCollectionAssignment_CustomCollection_ReferencesInstance()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var originalCollection = new CustomCollection<int>();
|
||||
var capturedValue = (CustomCollection<int>)null;
|
||||
|
||||
var component = DropDownWithReferenceCollection<CustomCollection<int>>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
parameters.Add(p => p.Value, originalCollection);
|
||||
parameters.Add(p => p.ValueChanged, EventCallback.Factory.Create<CustomCollection<int>>(this, value => capturedValue = value));
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Id));
|
||||
});
|
||||
|
||||
var items = component.FindAll(".rz-multiselect-item");
|
||||
|
||||
// Select first item
|
||||
items[0].Click();
|
||||
component.Render();
|
||||
|
||||
// Verify the same custom collection instance is Referenced
|
||||
Assert.Same(originalCollection, capturedValue);
|
||||
|
||||
// Verify the item was added correctly
|
||||
Assert.Single(originalCollection);
|
||||
Assert.Contains(1, originalCollection);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DropDown_ReferenceGenericCollectionAssignment_List_ReferencesInstance()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var originalList = new List<int>();
|
||||
var capturedValue = (List<int>)null;
|
||||
|
||||
var component = DropDownWithReferenceCollection<List<int>>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
parameters.Add(p => p.Value, originalList);
|
||||
parameters.Add(p => p.ValueChanged, EventCallback.Factory.Create<List<int>>(this, value => capturedValue = value));
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Id));
|
||||
});
|
||||
|
||||
var items = component.FindAll(".rz-multiselect-item");
|
||||
|
||||
// Select first item
|
||||
items[0].Click();
|
||||
component.Render();
|
||||
|
||||
// For List<T>, it should now Reference the instance since we removed the IList exclusion
|
||||
// Arrays are now excluded instead
|
||||
Assert.Same(originalList, capturedValue);
|
||||
|
||||
// And the content should be correct
|
||||
Assert.Single(capturedValue);
|
||||
Assert.Contains(1, capturedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropDown_ReferenceGenericCollectionAssignment_DisabledByDefault()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var originalList = new List<int>();
|
||||
var capturedValue = (List<int>)null;
|
||||
|
||||
var component = DropDown<List<int>>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
parameters.Add(p => p.Value, originalList);
|
||||
parameters.Add(p => p.ValueChanged, EventCallback.Factory.Create<List<int>>(this, value => capturedValue = value));
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Id));
|
||||
});
|
||||
|
||||
var items = component.FindAll(".rz-multiselect-item");
|
||||
|
||||
// Select first item
|
||||
items[0].Click();
|
||||
component.Render();
|
||||
|
||||
// When ReferenceCollectionOnSelection is false (default), a new instance should be created
|
||||
Assert.NotSame(originalList, capturedValue);
|
||||
|
||||
// But the content should still be correct
|
||||
Assert.Single(capturedValue);
|
||||
Assert.Contains(1, capturedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropDown_Reset_PreservesCollectionInstanceButClears()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var originalHashSet = new HashSet<int> { 1, 2 }; // Pre-populate
|
||||
var capturedValues = new List<HashSet<int>>();
|
||||
|
||||
var component = DropDownWithReferenceCollection<HashSet<int>>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
parameters.Add(p => p.Value, originalHashSet);
|
||||
parameters.Add(p => p.ValueChanged, EventCallback.Factory.Create<HashSet<int>>(this, value => capturedValues.Add(value)));
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Id));
|
||||
});
|
||||
|
||||
// Verify initial state - collection should have 2 items
|
||||
Assert.Equal(2, originalHashSet.Count);
|
||||
Assert.Contains(1, originalHashSet);
|
||||
Assert.Contains(2, originalHashSet);
|
||||
|
||||
// Call Reset (public method that calls ClearAll internally)
|
||||
component.InvokeAsync(() => component.Instance.Reset());
|
||||
component.Render();
|
||||
|
||||
// Verify the same HashSet instance is preserved
|
||||
Assert.Single(capturedValues);
|
||||
Assert.Same(originalHashSet, capturedValues[0]);
|
||||
|
||||
// Verify the collection is now cleared
|
||||
Assert.Empty(originalHashSet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropDown_SelectAll_PreservesCollectionInstanceAndPopulates()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var originalHashSet = new HashSet<int>(); // Start empty
|
||||
var capturedValues = new List<HashSet<int>>();
|
||||
|
||||
var component = DropDownWithReferenceCollection<HashSet<int>>(ctx, parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Multiple, true);
|
||||
parameters.Add(p => p.AllowSelectAll, true);
|
||||
parameters.Add(p => p.Value, originalHashSet);
|
||||
parameters.Add(p => p.ValueChanged, EventCallback.Factory.Create<HashSet<int>>(this, value => capturedValues.Add(value)));
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Id));
|
||||
});
|
||||
|
||||
// Verify initial state - collection should be empty
|
||||
Assert.Empty(originalHashSet);
|
||||
|
||||
// Find and click the "Select All" checkbox
|
||||
var selectAllCheckBox = component.Find(".rz-multiselect-header input[type='checkbox']");
|
||||
selectAllCheckBox.Click();
|
||||
component.Render();
|
||||
|
||||
// Verify the same HashSet instance is preserved
|
||||
Assert.Single(capturedValues);
|
||||
Assert.Same(originalHashSet, capturedValues[0]);
|
||||
|
||||
// Verify the collection now contains both items
|
||||
Assert.Equal(2, originalHashSet.Count);
|
||||
Assert.Contains(1, originalHashSet);
|
||||
Assert.Contains(2, originalHashSet);
|
||||
}
|
||||
|
||||
class ReferenceCollectionDropDown<T> : Radzen.Blazor.RadzenDropDown<T>
|
||||
{
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
PreserveCollectionOnSelection = true;
|
||||
base.OnInitialized();
|
||||
}
|
||||
}
|
||||
|
||||
private static IRenderedComponent<ReferenceCollectionDropDown<T>> DropDownWithReferenceCollection<T>(TestContext ctx, Action<ComponentParameterCollectionBuilder<ReferenceCollectionDropDown<T>>> configure = null)
|
||||
{
|
||||
var data = new[] {
|
||||
new DataItem { Text = "Item 1", Id = 1 },
|
||||
new DataItem { Text = "Item 2", Id = 2 },
|
||||
};
|
||||
|
||||
var component = ctx.RenderComponent<ReferenceCollectionDropDown<T>>();
|
||||
|
||||
component.SetParametersAndRender(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Data, data);
|
||||
parameters.Add(p => p.TextProperty, nameof(DataItem.Text));
|
||||
|
||||
if (configure != null)
|
||||
{
|
||||
configure.Invoke(parameters);
|
||||
}
|
||||
else
|
||||
{
|
||||
parameters.Add(p => p.ValueProperty, nameof(DataItem.Id));
|
||||
}
|
||||
});
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
class DataItemComparer : IEqualityComparer<DataItem>, IEqualityComparer<object>
|
||||
{
|
||||
@@ -303,5 +670,21 @@ namespace Radzen.Blazor.Tests
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class CustomCollection<T> : ICollection<T>
|
||||
{
|
||||
private readonly List<T> _items = new();
|
||||
|
||||
public int Count => _items.Count;
|
||||
public bool IsReadOnly => false;
|
||||
|
||||
public void Add(T item) => _items.Add(item);
|
||||
public void Clear() => _items.Clear();
|
||||
public bool Contains(T item) => _items.Contains(item);
|
||||
public void CopyTo(T[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex);
|
||||
public bool Remove(T item) => _items.Remove(item);
|
||||
public IEnumerator<T> GetEnumerator() => _items.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1557
Radzen.Blazor.Tests/ExpressionParserTests.cs
Normal file
1557
Radzen.Blazor.Tests/ExpressionParserTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
323
Radzen.Blazor.Tests/ExpressionSerializerTests.cs
Normal file
323
Radzen.Blazor.Tests/ExpressionSerializerTests.cs
Normal file
@@ -0,0 +1,323 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
{
|
||||
class TestEntity
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Age { get; set; }
|
||||
public double Salary { get; set; }
|
||||
public float Score { get; set; }
|
||||
public decimal Balance { get; set; }
|
||||
public short Level { get; set; }
|
||||
public long Population { get; set; }
|
||||
public Status AccountStatus { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTimeOffset LastUpdated { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public TimeOnly StartTime { get; set; }
|
||||
public DateOnly BirthDate { get; set; }
|
||||
public int[] Scores { get; set; }
|
||||
public List<string> Tags { get; set; }
|
||||
public List<TestEntity> Children { get; set; }
|
||||
public Address Address { get; set; }
|
||||
public double[] Salaries { get; set; }
|
||||
public float[] Heights { get; set; }
|
||||
public decimal[] Balances { get; set; }
|
||||
public short[] Levels { get; set; }
|
||||
public long[] Populations { get; set; }
|
||||
public string[] Names { get; set; }
|
||||
public Guid[] Ids { get; set; }
|
||||
public DateTime[] CreatedDates { get; set; }
|
||||
public DateTimeOffset[] UpdatedDates { get; set; }
|
||||
public TimeOnly[] StartTimes { get; set; }
|
||||
public DateOnly[] BirthDates { get; set; }
|
||||
public Status[] Statuses { get; set; }
|
||||
}
|
||||
|
||||
enum Status
|
||||
{
|
||||
Active,
|
||||
Inactive,
|
||||
Suspended
|
||||
}
|
||||
|
||||
class Address
|
||||
{
|
||||
public string City { get; set; }
|
||||
public string Country { get; set; }
|
||||
}
|
||||
|
||||
public class ExpressionSerializerTests
|
||||
{
|
||||
private readonly ExpressionSerializer _serializer = new ExpressionSerializer();
|
||||
|
||||
[Fact]
|
||||
public void Serializes_SimpleBinaryExpression()
|
||||
{
|
||||
Expression<Func<int, bool>> expr = e => e > 10;
|
||||
Assert.Equal("e => (e > 10)", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_StringEquality()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Name == "John";
|
||||
Assert.Equal("e => (e.Name == \"John\")", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_IntComparison()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Age > 18;
|
||||
Assert.Equal("e => (e.Age > 18)", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_DoubleComparison()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Salary < 50000.50;
|
||||
Assert.Equal("e => (e.Salary < 50000.5)", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_FloatComparison()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Score >= 85.3f;
|
||||
Assert.Equal("e => (e.Score >= 85.3)", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_DecimalComparison()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Balance <= 1000.75m;
|
||||
Assert.Equal("e => (e.Balance <= 1000.75)", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ShortComparison()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Level == 3;
|
||||
Assert.Equal("e => (e.Level == 3)", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_LongComparison()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Population > 1000000L;
|
||||
Assert.Equal("e => (e.Population > 1000000)", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_EnumComparison()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.AccountStatus == Status.Inactive;
|
||||
Assert.Equal("e => (e.AccountStatus == 1)", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ArrayContainsValue()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Scores.Contains(100);
|
||||
Assert.Equal("e => e.Scores.Contains(100)", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ArrayNotContainsValue()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => !e.Scores.Contains(100);
|
||||
Assert.Equal("e => (!(e.Scores.Contains(100)))", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ArrayInValue()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Scores.Intersect(new [] { 100 }).Any();
|
||||
Assert.Equal("e => e.Scores.Intersect(new [] { 100 }).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ArrayNotInValue()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Scores.Except(new[] { 100 }).Any();
|
||||
Assert.Equal("e => e.Scores.Except(new [] { 100 }).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { 100 }.Intersect(e.Scores).Any();
|
||||
Assert.Equal("e => new [] { 100 }.Intersect(e.Scores).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ArrayNotInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { 100 }.Except(e.Scores).Any();
|
||||
Assert.Equal("e => new [] { 100 }.Except(e.Scores).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_IntArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { 100 }.Intersect(e.Scores).Any();
|
||||
Assert.Equal("e => new [] { 100 }.Intersect(e.Scores).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_IntArrayNotInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => !new[] { 100 }.Intersect(e.Scores).Any();
|
||||
Assert.Equal("e => (!(new [] { 100 }.Intersect(e.Scores).Any()))", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_DoubleArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { 99.99 }.Intersect(e.Salaries).Any();
|
||||
Assert.Equal("e => new [] { 99.99 }.Intersect(e.Salaries).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_FloatArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { 5.5f }.Intersect(e.Heights).Any();
|
||||
Assert.Equal("e => new [] { 5.5 }.Intersect(e.Heights).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_DecimalArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { 1000.75m }.Intersect(e.Balances).Any();
|
||||
Assert.Equal("e => new [] { 1000.75 }.Intersect(e.Balances).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ShortArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new [] { (short)3 }.Intersect(e.Levels).Any();
|
||||
Assert.Equal("e => new [] { 3 }.Intersect(e.Levels).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_LongArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new [] { 1000000L }.Intersect(e.Populations).Any();
|
||||
Assert.Equal("e => new [] { 1000000 }.Intersect(e.Populations).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_StringArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { "Alice", "Bob" }.Intersect(e.Names).Any();
|
||||
Assert.Equal("e => (new [] { \"Alice\", \"Bob\" }).Intersect(e.Names).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_GuidArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { Guid.Parse("12345678-1234-1234-1234-123456789abc") }.Intersect(e.Ids).Any();
|
||||
Assert.Equal("e => (new [] { Guid.Parse(\"12345678-1234-1234-1234-123456789abc\") }).Intersect(e.Ids).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_DateTimeArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { DateTime.Parse("2023-01-01T00:00:00.000Z") }.Intersect(e.CreatedDates).Any();
|
||||
Assert.Equal("e => (new [] { DateTime.Parse(\"2023-01-01T00:00:00.000Z\") }).Intersect(e.CreatedDates).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_DateTimeOffsetArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { DateTimeOffset.Parse("2023-01-01T10:30:00.000+00:00") }.Intersect(e.UpdatedDates).Any();
|
||||
Assert.Equal("e => (new [] { DateTimeOffset.Parse(\"2023-01-01T10:30:00.000+00:00\") }).Intersect(e.UpdatedDates).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_DateTimeWithRoundtripKind()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e =>
|
||||
DateTime.Parse("2023-01-01T00:00:00.000Z", null, DateTimeStyles.RoundtripKind) > e.CreatedAt;
|
||||
|
||||
Assert.Equal(
|
||||
"e => (DateTime.Parse(\"2023-01-01T00:00:00.000Z\", null, (System.Globalization.DateTimeStyles)128) > e.CreatedAt)",
|
||||
_serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_TimeOnlyArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { TimeOnly.Parse("12:00:00") }.Intersect(e.StartTimes).Any();
|
||||
Assert.Equal("e => (new [] { TimeOnly.Parse(\"12:00:00\") }).Intersect(e.StartTimes).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_DateOnlyArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { DateOnly.Parse("2000-01-01") }.Intersect(e.BirthDates).Any();
|
||||
Assert.Equal("e => (new [] { DateOnly.Parse(\"2000-01-01\") }).Intersect(e.BirthDates).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_EnumArrayInValueOposite()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => new[] { Status.Active, Status.Inactive }.Intersect(e.Statuses).Any();
|
||||
Assert.Equal("e => (new [] { (Radzen.Blazor.Tests.Status)0, (Radzen.Blazor.Tests.Status)1 }).Intersect(e.Statuses).Any()", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ListContainsValue()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Tags.Contains("VIP");
|
||||
Assert.Equal("e => e.Tags.Contains(\"VIP\")", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ListNotContainsValue()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => !e.Tags.Contains("VIP");
|
||||
Assert.Equal("e => (!(e.Tags.Contains(\"VIP\")))", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ListAnyCheck()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Children.Any(c => c.Age > 18);
|
||||
Assert.Equal("e => e.Children.Any(c => (c.Age > 18))", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ListNotAnyCheck()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => !e.Children.Any(c => c.Age > 18);
|
||||
Assert.Equal("e => (!(e.Children.Any(c => (c.Age > 18))))", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_EntitySubPropertyCheck()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Address.City == "New York";
|
||||
Assert.Equal("e => (e.Address.City == \"New York\")", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_ComplexExpressionWithProperties()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => e.Age > 18 && e.Tags.Contains("Member") || e.Address.City == "London";
|
||||
Assert.Equal("e => (((e.Age > 18) && e.Tags.Contains(\"Member\")) || (e.Address.City == \"London\"))", _serializer.Serialize(expr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializes_NotContains()
|
||||
{
|
||||
Expression<Func<TestEntity, bool>> expr = e => !e.Tags.Contains("Member");
|
||||
Assert.Equal("e => (!(e.Tags.Contains(\"Member\")))", _serializer.Serialize(expr));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,13 +184,13 @@ namespace Radzen.Blazor.Tests
|
||||
|
||||
Assert.Contains("SummaryContent", component.Markup);
|
||||
Assert.Equal(
|
||||
"",
|
||||
component.Find(".rz-fieldset-content-summary").ParentElement.Attributes.First(attr => attr.Name == "style").Value
|
||||
"false",
|
||||
component.Find(".rz-fieldset-content-summary").ParentElement.ParentElement.Attributes.First(attr => attr.Name == "aria-hidden").Value
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fieldset_DontRenders_SummaryWhenOpen()
|
||||
public void Fieldset_DoesNotRender_SummaryWhenOpen()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
var component = ctx.RenderComponent<RadzenFieldset>();
|
||||
@@ -210,8 +210,8 @@ namespace Radzen.Blazor.Tests
|
||||
|
||||
Assert.Contains("SummaryContent", component.Markup);
|
||||
Assert.Equal(
|
||||
"display: none",
|
||||
component.Find(".rz-fieldset-content-summary").ParentElement.Attributes.First(attr => attr.Name == "style").Value
|
||||
"true",
|
||||
component.Find(".rz-fieldset-content-summary").ParentElement.ParentElement.Attributes.First(attr => attr.Name == "aria-hidden").Value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,20 @@ namespace Radzen.Blazor.Tests
|
||||
Assert.Contains(@$"target=""{target}""", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Link_Renders_DisabledParameter()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
|
||||
var component = ctx.RenderComponent<RadzenLink>();
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.Add(p => p.Disabled, true));
|
||||
|
||||
Assert.Contains("class=\"rz-link rz-link-disabled active\"", component.Markup);
|
||||
|
||||
Assert.DoesNotContain("href=", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Icon_Renders_UnmatchedParameter()
|
||||
{
|
||||
|
||||
314
Radzen.Blazor.Tests/Markdown/BlockQuoteTests.cs
Normal file
314
Radzen.Blazor.Tests/Markdown/BlockQuoteTests.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class BlockQuoteTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BasicBlockQuote()
|
||||
{
|
||||
|
||||
Assert.Equal(@"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>", ToXml(@"> foo"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"> # Foo
|
||||
> bar
|
||||
> baz", @"<document>
|
||||
<block_quote>
|
||||
<heading level=""1"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"># Foo
|
||||
>bar
|
||||
> baz", @"<document>
|
||||
<block_quote>
|
||||
<heading level=""1"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@" > # Foo
|
||||
> bar
|
||||
> baz", @"<document>
|
||||
<block_quote>
|
||||
<heading level=""1"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@" > # Foo
|
||||
> bar
|
||||
> baz", @"<document>
|
||||
<code_block>> # Foo
|
||||
> bar
|
||||
> baz
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"> # Foo
|
||||
> bar
|
||||
baz", @"<document>
|
||||
<block_quote>
|
||||
<heading level=""1"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"> bar
|
||||
baz
|
||||
> foo", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
<softbreak />
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"> foo
|
||||
---", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"> - foo
|
||||
- bar", @"<document>
|
||||
<block_quote>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</block_quote>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"> foo
|
||||
bar", @"<document>
|
||||
<block_quote>
|
||||
<code_block>foo
|
||||
</code_block>
|
||||
</block_quote>
|
||||
<code_block>bar
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"> ```
|
||||
foo
|
||||
```", @"<document>
|
||||
<block_quote>
|
||||
<code_block></code_block>
|
||||
</block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<code_block></code_block>
|
||||
</document>")]
|
||||
[InlineData(@"> foo
|
||||
- bar", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<softbreak />
|
||||
<text>- bar</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@">", @"<document>
|
||||
<block_quote />
|
||||
</document>")]
|
||||
[InlineData(@">
|
||||
>
|
||||
> ", @"<document>
|
||||
<block_quote />
|
||||
</document>")]
|
||||
[InlineData(@">
|
||||
> foo
|
||||
> ", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"> foo
|
||||
|
||||
> bar", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"> foo
|
||||
>
|
||||
> bar", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"foo
|
||||
> bar", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"> aaa
|
||||
***
|
||||
> bbb", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>aaa</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
<thematic_break />
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bbb</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"> bar
|
||||
baz", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"> bar
|
||||
|
||||
baz", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"> bar
|
||||
>
|
||||
baz", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"> > > foo
|
||||
bar", @"<document>
|
||||
<block_quote>
|
||||
<block_quote>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<softbreak />
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</block_quote>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@">>> foo
|
||||
> bar
|
||||
>>baz", @"<document>
|
||||
<block_quote>
|
||||
<block_quote>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<softbreak />
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</block_quote>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"> code
|
||||
|
||||
> not code", @"<document>
|
||||
<block_quote>
|
||||
<code_block>code
|
||||
</code_block>
|
||||
</block_quote>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>not code</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
public void Parse_BlockQuote(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
175
Radzen.Blazor.Tests/Markdown/CodeTests.cs
Normal file
175
Radzen.Blazor.Tests/Markdown/CodeTests.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class CodeTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("`foo`",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<code>foo</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("`` foo ` bar ``",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<code>foo ` bar</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("` `` `",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<code>``</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_BasicCode_ReturnsCodeNode(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("` `` `",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<code> `` </code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("` a`",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<code> a</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"` `
|
||||
` `",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<code> </code>
|
||||
<softbreak />
|
||||
<code> </code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_CodeWithSpaces_PreservesSpaces(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"``
|
||||
foo
|
||||
bar
|
||||
baz
|
||||
``",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<code>foo bar baz</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"``
|
||||
foo
|
||||
``",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<code>foo </code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"`foo bar
|
||||
baz`",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<code>foo bar baz</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
|
||||
public void Parse_CodeWithLineBreaks_ConvertsToSpace(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("`foo\\`bar`", @"<document>
|
||||
<paragraph>
|
||||
<code>foo\</code>
|
||||
<text>bar</text>
|
||||
<text>`</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("``foo`bar``", @"<document>
|
||||
<paragraph>
|
||||
<code>foo`bar</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("` foo `` bar `", @"<document>
|
||||
<paragraph>
|
||||
<code>foo `` bar</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_CodeWithBacktics(string mardown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(mardown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("*foo`*`", @"<document>
|
||||
<paragraph>
|
||||
<text>*</text>
|
||||
<text>foo</text>
|
||||
<code>*</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("[not a `link](/foo`)",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>not a </text>
|
||||
<code>link](/foo</code>
|
||||
<text>)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("`<https://foo.bar.`baz>`",@"<document>
|
||||
<paragraph>
|
||||
<code><https://foo.bar.</code>
|
||||
<text>baz></text>
|
||||
<text>`</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_CodePrecedence(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("```foo``", @"<document>
|
||||
<paragraph>
|
||||
<text>```</text>
|
||||
<text>foo</text>
|
||||
<text>``</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("`foo", @"<document>
|
||||
<paragraph>
|
||||
<text>`</text>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("`foo``bar``", @"<document>
|
||||
<paragraph>
|
||||
<text>`</text>
|
||||
<text>foo</text>
|
||||
<code>bar</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_UnmatchingBacktics(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
272
Radzen.Blazor.Tests/Markdown/EmphasisTests.cs
Normal file
272
Radzen.Blazor.Tests/Markdown/EmphasisTests.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class EmphasisTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"**foo** bar
|
||||
baz",@"<document>
|
||||
<paragraph>
|
||||
<strong>
|
||||
<text>foo</text>
|
||||
</strong>
|
||||
<text> bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("*foo bar*",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo bar</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("a * foo bar*",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>a </text>
|
||||
<text>*</text>
|
||||
<text> foo bar</text>
|
||||
<text>*</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("a*\"foo\"*",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
<text>*</text>
|
||||
<text>""foo""</text>
|
||||
<text>*</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("* a *",
|
||||
@"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a </text>
|
||||
<text>*</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData("foo*bar*",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<emph>
|
||||
<text>bar</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("5*6*78",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>5</text>
|
||||
<emph>
|
||||
<text>6</text>
|
||||
</emph>
|
||||
<text>78</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("_foo bar_",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo bar</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("_ foo bar_",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>_</text>
|
||||
<text> foo bar</text>
|
||||
<text>_</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("a_\"foo\"_",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
<text>_</text>
|
||||
<text>""foo""</text>
|
||||
<text>_</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("foo_bar_",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<text>_</text>
|
||||
<text>bar</text>
|
||||
<text>_</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("5_6_78",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>5</text>
|
||||
<text>_</text>
|
||||
<text>6</text>
|
||||
<text>_</text>
|
||||
<text>78</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("пристаням_стремятся_",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>пристаням</text>
|
||||
<text>_</text>
|
||||
<text>стремятся</text>
|
||||
<text>_</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("aa_\"bb\"_cc",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>aa</text>
|
||||
<text>_</text>
|
||||
<text>""bb""</text>
|
||||
<text>_</text>
|
||||
<text>cc</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("foo-_(bar)_",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>foo-</text>
|
||||
<emph>
|
||||
<text>(bar)</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("_foo*",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>_</text>
|
||||
<text>foo</text>
|
||||
<text>*</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("*foo bar *",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>*</text>
|
||||
<text>foo bar </text>
|
||||
<text>*</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("*foo bar\nbaz*",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("*(*foo)",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>*</text>
|
||||
<text>(</text>
|
||||
<text>*</text>
|
||||
<text>foo)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("*(*foo*)*",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>(</text>
|
||||
<emph>
|
||||
<text>foo</text>
|
||||
</emph>
|
||||
<text>)</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("*foo*bar",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo</text>
|
||||
</emph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("_foo bar _",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>_</text>
|
||||
<text>foo bar </text>
|
||||
<text>_</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("_(_foo_)_",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>(</text>
|
||||
<emph>
|
||||
<text>foo</text>
|
||||
</emph>
|
||||
<text>)</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("_foo_bar",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>_</text>
|
||||
<text>foo</text>
|
||||
<text>_</text>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("_пристаням_стремятся",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>_</text>
|
||||
<text>пристаням</text>
|
||||
<text>_</text>
|
||||
<text>стремятся</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("_foo_bar_baz_",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo</text>
|
||||
<text>_</text>
|
||||
<text>bar</text>
|
||||
<text>_</text>
|
||||
<text>baz</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("_(bar)_.",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>(bar)</text>
|
||||
</emph>
|
||||
<text>.</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_EmphasisRules_AdheresToCommonMarkSpec(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
264
Radzen.Blazor.Tests/Markdown/FencedCodeBlockTests.cs
Normal file
264
Radzen.Blazor.Tests/Markdown/FencedCodeBlockTests.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class FencedCodeBlockTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BasicFencedCodeBlock()
|
||||
{
|
||||
Assert.Equal(@"<document>
|
||||
<code_block>foo
|
||||
</code_block>
|
||||
</document>", ToXml(@"```
|
||||
foo
|
||||
```"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"```
|
||||
<
|
||||
>
|
||||
```", @"<document>
|
||||
<code_block><
|
||||
>
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"~~~
|
||||
<
|
||||
>
|
||||
~~~", @"<document>
|
||||
<code_block><
|
||||
>
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"``
|
||||
foo
|
||||
``", @"<document>
|
||||
<paragraph>
|
||||
<code>foo</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"```
|
||||
aaa
|
||||
~~~
|
||||
```", @"<document>
|
||||
<code_block>aaa
|
||||
~~~
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"~~~
|
||||
aaa
|
||||
```
|
||||
~~~", @"<document>
|
||||
<code_block>aaa
|
||||
```
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"````
|
||||
aaa
|
||||
```
|
||||
``````", @"<document>
|
||||
<code_block>aaa
|
||||
```
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"~~~~
|
||||
aaa
|
||||
~~~
|
||||
~~~~", @"<document>
|
||||
<code_block>aaa
|
||||
~~~
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"```", @"<document>
|
||||
<code_block></code_block>
|
||||
</document>")]
|
||||
[InlineData(@"`````
|
||||
|
||||
```
|
||||
aaa", @"<document>
|
||||
<code_block>
|
||||
```
|
||||
aaa
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"> ```
|
||||
> aaa
|
||||
|
||||
bbb", @"<document>
|
||||
<block_quote>
|
||||
<code_block>aaa
|
||||
</code_block>
|
||||
</block_quote>
|
||||
<paragraph>
|
||||
<text>bbb</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"```
|
||||
|
||||
|
||||
```", @"<document>
|
||||
<code_block>
|
||||
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"```
|
||||
```", @"<document>
|
||||
<code_block></code_block>
|
||||
</document>")]
|
||||
[InlineData(@" ```
|
||||
aaa
|
||||
aaa
|
||||
```", @"<document>
|
||||
<code_block>aaa
|
||||
aaa
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" ```
|
||||
aaa
|
||||
aaa
|
||||
aaa
|
||||
```", @"<document>
|
||||
<code_block>aaa
|
||||
aaa
|
||||
aaa
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" ```
|
||||
aaa
|
||||
aaa
|
||||
aaa
|
||||
```", @"<document>
|
||||
<code_block>aaa
|
||||
aaa
|
||||
aaa
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" ```
|
||||
aaa
|
||||
```", @"<document>
|
||||
<code_block>```
|
||||
aaa
|
||||
```
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"```
|
||||
aaa
|
||||
```", @"<document>
|
||||
<code_block>aaa
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" ```
|
||||
aaa
|
||||
````", @"<document>
|
||||
<code_block>aaa
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"```
|
||||
aaa
|
||||
```", @"<document>
|
||||
<code_block>aaa
|
||||
```
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"``` ```
|
||||
aaa", @"<document>
|
||||
<paragraph>
|
||||
<code> </code>
|
||||
<softbreak />
|
||||
<text>aaa</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"~~~~~~
|
||||
aaa
|
||||
~~~ ~~", @"<document>
|
||||
<code_block>aaa
|
||||
~~~ ~~
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"foo
|
||||
```
|
||||
bar
|
||||
```
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<code_block>bar
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo
|
||||
---
|
||||
~~~
|
||||
bar
|
||||
~~~
|
||||
# baz", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<code_block>bar
|
||||
</code_block>
|
||||
<heading level=""1"">
|
||||
<text>baz</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"```ruby
|
||||
def foo(x)
|
||||
return 3
|
||||
end
|
||||
```", @"<document>
|
||||
<code_block info=""ruby"">def foo(x)
|
||||
return 3
|
||||
end
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"~~~~ ruby startline=3 $%@#$
|
||||
def foo(x)
|
||||
return 3
|
||||
end
|
||||
~~~~~~~", @"<document>
|
||||
<code_block info=""ruby startline=3 $%@#$"">def foo(x)
|
||||
return 3
|
||||
end
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"````;
|
||||
````", @"<document>
|
||||
<code_block info="";""></code_block>
|
||||
</document>")]
|
||||
[InlineData(@"``` aa ```
|
||||
foo", @"<document>
|
||||
<paragraph>
|
||||
<code>aa</code>
|
||||
<softbreak />
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"~~~ aa ``` ~~~
|
||||
foo
|
||||
~~~", @"<document>
|
||||
<code_block info=""aa ``` ~~~"">foo
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"```
|
||||
``` aaa
|
||||
```", @"<document>
|
||||
<code_block>``` aaa
|
||||
</code_block>
|
||||
</document>")]
|
||||
public void Parse_FencedCodeBlock(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
132
Radzen.Blazor.Tests/Markdown/HardLineBreakTests.cs
Normal file
132
Radzen.Blazor.Tests/Markdown/HardLineBreakTests.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class HardLineBreakTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"foo
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<linebreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("foo \r\nbaz", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<linebreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo\
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<linebreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<linebreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo
|
||||
bar", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<linebreak />
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo\
|
||||
bar", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<linebreak />
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"*foo
|
||||
bar*", @"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo</text>
|
||||
<linebreak />
|
||||
<text>bar</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"*foo\
|
||||
bar*", @"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo</text>
|
||||
<linebreak />
|
||||
<text>bar</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"`code\
|
||||
span`", @"<document>
|
||||
<paragraph>
|
||||
<code>code\ span</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"`code
|
||||
span`", @"<document>
|
||||
<paragraph>
|
||||
<code>code span</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<a href=""foo
|
||||
bar"">", @"<document>
|
||||
<paragraph>
|
||||
<html_inline><a href=""foo
|
||||
bar""></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<a href=""foo\
|
||||
bar"">", @"<document>
|
||||
<paragraph>
|
||||
<html_inline><a href=""foo\
|
||||
bar""></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo\", @"<document>
|
||||
<paragraph>
|
||||
<text>foo\</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo ", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"### foo\", @"<document>
|
||||
<heading level=""3"">
|
||||
<text>foo\</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"### foo ", @"<document>
|
||||
<heading level=""3"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
public void Parse_HardLineBreak(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
511
Radzen.Blazor.Tests/Markdown/HeadingTests.cs
Normal file
511
Radzen.Blazor.Tests/Markdown/HeadingTests.cs
Normal file
@@ -0,0 +1,511 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class HeadingTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BasicAtxHeading()
|
||||
{
|
||||
Assert.Equal(@"<document>
|
||||
<heading level=""1"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
</document>", ToXml("# foo"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"# foo
|
||||
## foo
|
||||
### foo
|
||||
#### foo
|
||||
##### foo
|
||||
###### foo", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<heading level=""2"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<heading level=""3"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<heading level=""4"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<heading level=""5"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<heading level=""6"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"####### foo", @"<document>
|
||||
<paragraph>
|
||||
<text>####### foo</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"#5 bolt
|
||||
|
||||
#hashtag", @"<document>
|
||||
<paragraph>
|
||||
<text>#5 bolt</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>#hashtag</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"\## foo", @"<document>
|
||||
<paragraph>
|
||||
<text>#</text>
|
||||
<text># foo</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"# foo *bar* \*baz\*", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>foo </text>
|
||||
<emph>
|
||||
<text>bar</text>
|
||||
</emph>
|
||||
<text> </text>
|
||||
<text>*</text>
|
||||
<text>baz</text>
|
||||
<text>*</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"# foo ", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@" ### foo
|
||||
## foo
|
||||
# foo", @"<document>
|
||||
<heading level=""3"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<heading level=""2"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<heading level=""1"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@" # foo", @"<document>
|
||||
<code_block># foo
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"foo
|
||||
# bar", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<softbreak />
|
||||
<text># bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"## foo ##
|
||||
### bar ###", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<heading level=""3"">
|
||||
<text>bar</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"# foo ##################################
|
||||
##### foo ##", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<heading level=""5"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"### foo ### ", @"<document>
|
||||
<heading level=""3"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"### foo ### b", @"<document>
|
||||
<heading level=""3"">
|
||||
<text>foo ### b</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"# foo#", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>foo#</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"### foo \###
|
||||
## foo #\##
|
||||
# foo \#", @"<document>
|
||||
<heading level=""3"">
|
||||
<text>foo </text>
|
||||
<text>#</text>
|
||||
<text>##</text>
|
||||
</heading>
|
||||
<heading level=""2"">
|
||||
<text>foo #</text>
|
||||
<text>#</text>
|
||||
<text>#</text>
|
||||
</heading>
|
||||
<heading level=""1"">
|
||||
<text>foo </text>
|
||||
<text>#</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"****
|
||||
## foo
|
||||
****", @"<document>
|
||||
<thematic_break />
|
||||
<heading level=""2"">
|
||||
<text>foo</text>
|
||||
</heading>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"Foo bar
|
||||
# baz
|
||||
Bar foo", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo bar</text>
|
||||
</paragraph>
|
||||
<heading level=""1"">
|
||||
<text>baz</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>Bar foo</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"##
|
||||
#
|
||||
### ###", @"<document>
|
||||
<heading level=""2"" />
|
||||
<heading level=""1"" />
|
||||
<heading level=""3"" />
|
||||
</document>")]
|
||||
public void Parse_AtxHeading(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"Foo *bar*
|
||||
=========
|
||||
|
||||
Foo *baz*
|
||||
---------", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>Foo </text>
|
||||
<emph>
|
||||
<text>bar</text>
|
||||
</emph>
|
||||
</heading>
|
||||
<heading level=""2"">
|
||||
<text>Foo </text>
|
||||
<emph>
|
||||
<text>baz</text>
|
||||
</emph>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"Foo *bar
|
||||
baz*
|
||||
====", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>Foo </text>
|
||||
<emph>
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</emph>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@" Foo *bar
|
||||
baz*
|
||||
====", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>Foo </text>
|
||||
<emph>
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</emph>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
-------------------------
|
||||
|
||||
Foo
|
||||
=", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
<heading level=""1"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@" Foo
|
||||
---
|
||||
|
||||
Foo
|
||||
-----
|
||||
|
||||
Foo
|
||||
===", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
<heading level=""2"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
<heading level=""1"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@" Foo
|
||||
---
|
||||
|
||||
Foo
|
||||
---", @"<document>
|
||||
<code_block>Foo
|
||||
---
|
||||
|
||||
Foo
|
||||
</code_block>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
---- ", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
---", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<text>---</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
= =
|
||||
|
||||
Foo
|
||||
--- -", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<text>= =</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
-----", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"Foo\
|
||||
----", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>Foo\</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"`Foo
|
||||
----
|
||||
`
|
||||
|
||||
<a title=""a lot
|
||||
---
|
||||
of dashes""/>", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>`</text>
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>`</text>
|
||||
</paragraph>
|
||||
<heading level=""2"">
|
||||
<text><a title=""a lot</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>of dashes""/></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"> Foo
|
||||
---", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"> foo
|
||||
bar
|
||||
===", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<softbreak />
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>===</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"- Foo
|
||||
---", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
Bar
|
||||
---", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<text>Bar</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"---
|
||||
Foo
|
||||
---
|
||||
Bar
|
||||
---
|
||||
Baz", @"<document>
|
||||
<thematic_break />
|
||||
<heading level=""2"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
<heading level=""2"">
|
||||
<text>Bar</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>Baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"
|
||||
====", @"<document>
|
||||
<paragraph>
|
||||
<text>====</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"---
|
||||
---", @"<document>
|
||||
<thematic_break />
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
-----", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@" foo
|
||||
---", @"<document>
|
||||
<code_block>foo
|
||||
</code_block>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"> foo
|
||||
-----", @"<document>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"\> foo
|
||||
------", @"<document>
|
||||
<heading level=""2"">
|
||||
<text>> foo</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
|
||||
bar
|
||||
---
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
<heading level=""2"">
|
||||
<text>bar</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
bar
|
||||
|
||||
---
|
||||
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
<thematic_break />
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
bar
|
||||
* * *
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
<thematic_break />
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
bar
|
||||
\---
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<text>bar</text>
|
||||
<softbreak />
|
||||
<text>-</text>
|
||||
<text>--</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_SetExtHeading(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
487
Radzen.Blazor.Tests/Markdown/HtmlBlockTests.cs
Normal file
487
Radzen.Blazor.Tests/Markdown/HtmlBlockTests.cs
Normal file
@@ -0,0 +1,487 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class HtmlBlockTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"<table><tr><td>
|
||||
<pre>
|
||||
**Hello**,
|
||||
|
||||
_world_.
|
||||
</pre>
|
||||
</td></tr></table>", @"<document>
|
||||
<html_block><table><tr><td>
|
||||
<pre>
|
||||
**Hello**,</html_block>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>world</text>
|
||||
</emph>
|
||||
<text>.</text>
|
||||
<softbreak />
|
||||
<html_inline></pre></html_inline>
|
||||
</paragraph>
|
||||
<html_block></td></tr></table></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<table>
|
||||
<tr>
|
||||
<td>
|
||||
hi
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
okay.", @"<document>
|
||||
<html_block><table>
|
||||
<tr>
|
||||
<td>
|
||||
hi
|
||||
</td>
|
||||
</tr>
|
||||
</table></html_block>
|
||||
<paragraph>
|
||||
<text>okay.</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" <div>
|
||||
*hello*
|
||||
<foo><a>", @"<document>
|
||||
<html_block> <div>
|
||||
*hello*
|
||||
<foo><a></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"</div>
|
||||
*foo*", @"<document>
|
||||
<html_block></div>
|
||||
*foo*</html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<DIV CLASS=""foo"">
|
||||
|
||||
*Markdown*
|
||||
|
||||
</DIV>", @"<document>
|
||||
<html_block><DIV CLASS=""foo""></html_block>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>Markdown</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
<html_block></DIV></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<div id=""foo""
|
||||
class=""bar"">
|
||||
</div>", @"<document>
|
||||
<html_block><div id=""foo""
|
||||
class=""bar"">
|
||||
</div></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<div id=""foo"" class=""bar
|
||||
baz"">
|
||||
</div>", @"<document>
|
||||
<html_block><div id=""foo"" class=""bar
|
||||
baz"">
|
||||
</div></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<div>
|
||||
*foo*
|
||||
|
||||
*bar*", @"<document>
|
||||
<html_block><div>
|
||||
*foo*</html_block>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>bar</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<div id=""foo""
|
||||
*hi*", @"<document>
|
||||
<html_block><div id=""foo""
|
||||
*hi*</html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<div class
|
||||
foo", @"<document>
|
||||
<html_block><div class
|
||||
foo</html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<div *???-&&&-<---
|
||||
*foo*", @"<document>
|
||||
<html_block><div *???-&&&-<---
|
||||
*foo*</html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<div><a href=""bar"">*foo*</a></div>", @"<document>
|
||||
<html_block><div><a href=""bar"">*foo*</a></div></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<table><tr><td>
|
||||
foo
|
||||
</td></tr></table>", @"<document>
|
||||
<html_block><table><tr><td>
|
||||
foo
|
||||
</td></tr></table></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<div></div>
|
||||
``` c
|
||||
int x = 33;
|
||||
```", @"<document>
|
||||
<html_block><div></div>
|
||||
``` c
|
||||
int x = 33;
|
||||
```</html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<a href=""foo"">
|
||||
*bar*
|
||||
</a>", @"<document>
|
||||
<html_block><a href=""foo"">
|
||||
*bar*
|
||||
</a></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<Warning>
|
||||
*bar*
|
||||
</Warning>", @"<document>
|
||||
<html_block><Warning>
|
||||
*bar*
|
||||
</Warning></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<i class=""foo"">
|
||||
*bar*
|
||||
</i>", @"<document>
|
||||
<html_block><i class=""foo"">
|
||||
*bar*
|
||||
</i></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"</ins>
|
||||
*bar*", @"<document>
|
||||
<html_block></ins>
|
||||
*bar*</html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<del>
|
||||
*foo*
|
||||
</del>", @"<document>
|
||||
<html_block><del>
|
||||
*foo*
|
||||
</del></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<del>
|
||||
|
||||
*foo*
|
||||
|
||||
</del>", @"<document>
|
||||
<html_block><del></html_block>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
<html_block></del></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<del>*foo*</del>", @"<document>
|
||||
<paragraph>
|
||||
<html_inline><del></html_inline>
|
||||
<emph>
|
||||
<text>foo</text>
|
||||
</emph>
|
||||
<html_inline></del></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<pre language=""haskell""><code>
|
||||
import Text.HTML.TagSoup
|
||||
|
||||
main :: IO ()
|
||||
main = print $ parseTags tags
|
||||
</code></pre>
|
||||
okay", @"<document>
|
||||
<html_block><pre language=""haskell""><code>
|
||||
import Text.HTML.TagSoup
|
||||
|
||||
main :: IO ()
|
||||
main = print $ parseTags tags
|
||||
</code></pre></html_block>
|
||||
<paragraph>
|
||||
<text>okay</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<script type=""text/javascript"">
|
||||
// JavaScript example
|
||||
|
||||
document.getElementById(""demo"").innerHTML = ""Hello JavaScript!"";
|
||||
</script>
|
||||
okay", @"<document>
|
||||
<html_block><script type=""text/javascript"">
|
||||
// JavaScript example
|
||||
|
||||
document.getElementById(""demo"").innerHTML = ""Hello JavaScript!"";
|
||||
</script></html_block>
|
||||
<paragraph>
|
||||
<text>okay</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<textarea>
|
||||
|
||||
*foo*
|
||||
|
||||
_bar_
|
||||
|
||||
</textarea>", @"<document>
|
||||
<html_block><textarea>
|
||||
|
||||
*foo*
|
||||
|
||||
_bar_
|
||||
|
||||
</textarea></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<style
|
||||
type=""text/css"">
|
||||
h1 {color:red;}
|
||||
|
||||
p {color:blue;}
|
||||
</style>
|
||||
okay",@"<document>
|
||||
<html_block><style
|
||||
type=""text/css"">
|
||||
h1 {color:red;}
|
||||
|
||||
p {color:blue;}
|
||||
</style></html_block>
|
||||
<paragraph>
|
||||
<text>okay</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<style
|
||||
type=""text/css"">
|
||||
|
||||
foo", @"<document>
|
||||
<html_block><style
|
||||
type=""text/css"">
|
||||
|
||||
foo</html_block>
|
||||
</document>")]
|
||||
[InlineData(@"> <div>
|
||||
> foo
|
||||
|
||||
bar", @"<document>
|
||||
<block_quote>
|
||||
<html_block><div>
|
||||
foo</html_block>
|
||||
</block_quote>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"- <div>
|
||||
- foo", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<html_block><div></html_block>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"<style>p{color:red;}</style>
|
||||
*foo*", @"<document>
|
||||
<html_block><style>p{color:red;}</style></html_block>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<!-- foo -->*bar*
|
||||
*baz*", @"<document>
|
||||
<html_block><!-- foo -->*bar*</html_block>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>baz</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<script>
|
||||
foo
|
||||
</script>1. *bar*", @"<document>
|
||||
<html_block><script>
|
||||
foo
|
||||
</script>1. *bar*</html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<!-- Foo
|
||||
|
||||
bar
|
||||
baz -->
|
||||
okay", @"<document>
|
||||
<html_block><!-- Foo
|
||||
|
||||
bar
|
||||
baz --></html_block>
|
||||
<paragraph>
|
||||
<text>okay</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<?php
|
||||
|
||||
echo '>';
|
||||
|
||||
?>
|
||||
okay", @"<document>
|
||||
<html_block><?php
|
||||
|
||||
echo '>';
|
||||
|
||||
?></html_block>
|
||||
<paragraph>
|
||||
<text>okay</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<!DOCTYPE html>", @"<document>
|
||||
<html_block><!DOCTYPE html></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<![CDATA[
|
||||
function matchwo(a,b)
|
||||
{
|
||||
if (a < b && a < 0) then {
|
||||
return 1;
|
||||
|
||||
} else {
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
]]>
|
||||
okay", @"<document>
|
||||
<html_block><![CDATA[
|
||||
function matchwo(a,b)
|
||||
{
|
||||
if (a < b && a < 0) then {
|
||||
return 1;
|
||||
|
||||
} else {
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
]]></html_block>
|
||||
<paragraph>
|
||||
<text>okay</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" <!-- foo -->
|
||||
|
||||
<!-- foo -->", @"<document>
|
||||
<html_block> <!-- foo --></html_block>
|
||||
<code_block><!-- foo -->
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" <div>
|
||||
|
||||
<div>", @"<document>
|
||||
<html_block> <div></html_block>
|
||||
<code_block><div>
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
<div>
|
||||
bar
|
||||
</div>", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
<html_block><div>
|
||||
bar
|
||||
</div></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<div>
|
||||
bar
|
||||
</div>
|
||||
*foo*", @"<document>
|
||||
<html_block><div>
|
||||
bar
|
||||
</div>
|
||||
*foo*</html_block>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
<a href=""bar"">
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<html_inline><a href=""bar""></html_inline>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<div>
|
||||
|
||||
*Emphasized* text.
|
||||
|
||||
</div>", @"<document>
|
||||
<html_block><div></html_block>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>Emphasized</text>
|
||||
</emph>
|
||||
<text> text.</text>
|
||||
</paragraph>
|
||||
<html_block></div></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<div>
|
||||
*Emphasized* text.
|
||||
</div>", @"<document>
|
||||
<html_block><div>
|
||||
*Emphasized* text.
|
||||
</div></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<table>
|
||||
|
||||
<tr>
|
||||
|
||||
<td>
|
||||
Hi
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>", @"<document>
|
||||
<html_block><table></html_block>
|
||||
<html_block><tr></html_block>
|
||||
<html_block><td>
|
||||
Hi
|
||||
</td></html_block>
|
||||
<html_block></tr></html_block>
|
||||
<html_block></table></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"<table>
|
||||
|
||||
<tr>
|
||||
|
||||
<td>
|
||||
Hi
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>", @"<document>
|
||||
<html_block><table></html_block>
|
||||
<html_block> <tr></html_block>
|
||||
<code_block><td>
|
||||
Hi
|
||||
</td>
|
||||
</code_block>
|
||||
<html_block> </tr></html_block>
|
||||
<html_block></table></html_block>
|
||||
</document>")]
|
||||
public void Parse_HtmlBlock(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
144
Radzen.Blazor.Tests/Markdown/HtmlInlineTests.cs
Normal file
144
Radzen.Blazor.Tests/Markdown/HtmlInlineTests.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class HtmlInlineTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"<a><bab><c2c>", @"<document>
|
||||
<paragraph>
|
||||
<html_inline><a></html_inline>
|
||||
<html_inline><bab></html_inline>
|
||||
<html_inline><c2c></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<a/><b2/>", @"<document>
|
||||
<paragraph>
|
||||
<html_inline><a/></html_inline>
|
||||
<html_inline><b2/></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<a /><b2
|
||||
data=""foo"" >", @"<document>
|
||||
<paragraph>
|
||||
<html_inline><a /></html_inline>
|
||||
<html_inline><b2
|
||||
data=""foo"" ></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<a foo=""bar"" bam = 'baz <em>""</em>'
|
||||
_boolean zoop:33=zoop:33 />", @"<document>
|
||||
<paragraph>
|
||||
<html_inline><a foo=""bar"" bam = 'baz <em>""</em>'
|
||||
_boolean zoop:33=zoop:33 /></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"Foo <responsive-image src=""foo.jpg"" />", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo </text>
|
||||
<html_inline><responsive-image src=""foo.jpg"" /></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("<33> <__>", @"<document>
|
||||
<paragraph>
|
||||
<text><33> <</text>
|
||||
<text>__</text>
|
||||
<text>></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<a h*#ref=""hi"">", @"<document>
|
||||
<paragraph>
|
||||
<text><a h</text>
|
||||
<text>*</text>
|
||||
<text>#ref=""hi""></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<a href='bar'title=title>", @"<document>
|
||||
<paragraph>
|
||||
<text><a href='bar'title=title></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"</a></foo >", @"<document>
|
||||
<paragraph>
|
||||
<html_inline></a></html_inline>
|
||||
<html_inline></foo ></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"</a href=""foo"">", @"<document>
|
||||
<paragraph>
|
||||
<text></a href=""foo""></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo <!-- this is a --
|
||||
comment - with hyphens -->", @"<document>
|
||||
<paragraph>
|
||||
<text>foo </text>
|
||||
<html_inline><!-- this is a --
|
||||
comment - with hyphens --></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo <!--> foo -->
|
||||
|
||||
foo <!---> foo -->
|
||||
", @"<document>
|
||||
<paragraph>
|
||||
<text>foo </text>
|
||||
<html_inline><!--></html_inline>
|
||||
<text> foo --></text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>foo </text>
|
||||
<html_inline><!---></html_inline>
|
||||
<text> foo --></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo <?php echo $a; ?>", @"<document>
|
||||
<paragraph>
|
||||
<text>foo </text>
|
||||
<html_inline><?php echo $a; ?></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo <!ELEMENT br EMPTY>", @"<document>
|
||||
<paragraph>
|
||||
<text>foo </text>
|
||||
<html_inline><!ELEMENT br EMPTY></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo <![CDATA[>&<]]>", @"<document>
|
||||
<paragraph>
|
||||
<text>foo </text>
|
||||
<html_inline><![CDATA[>&<]]></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo <a href=""ö"">", @"<document>
|
||||
<paragraph>
|
||||
<text>foo </text>
|
||||
<html_inline><a href=""&ouml;""></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo <a href=""\*"">", @"<document>
|
||||
<paragraph>
|
||||
<text>foo </text>
|
||||
<html_inline><a href=""\*""></html_inline>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo <a href=""\"""">", @"<document>
|
||||
<paragraph>
|
||||
<text>foo <a href=""</text>
|
||||
<text>""</text>
|
||||
<text>""></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_Html(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
85
Radzen.Blazor.Tests/Markdown/ImageTests.cs
Normal file
85
Radzen.Blazor.Tests/Markdown/ImageTests.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class ImageTests
|
||||
{
|
||||
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"", @"<document>
|
||||
<paragraph>
|
||||
<image destination=""/url"" title=""title"">
|
||||
<text>foo</text>
|
||||
</image>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"](/url2)", @"<document>
|
||||
<paragraph>
|
||||
<image destination=""/url2"" title="""">
|
||||
<text>foo </text>
|
||||
<image destination=""/url"" title="""">
|
||||
<text>bar</text>
|
||||
</image>
|
||||
</image>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"](/url2)", @"<document>
|
||||
<paragraph>
|
||||
<image destination=""/url2"" title="""">
|
||||
<text>foo </text>
|
||||
<link destination=""/url"" title="""">
|
||||
<text>bar</text>
|
||||
</link>
|
||||
</image>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"", @"<document>
|
||||
<paragraph>
|
||||
<image destination=""train.jpg"" title="""">
|
||||
<text>foo</text>
|
||||
</image>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"My ", @"<document>
|
||||
<paragraph>
|
||||
<text>My </text>
|
||||
<image destination=""/path/to/train.jpg"" title=""title"">
|
||||
<text>foo bar</text>
|
||||
</image>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"", @"<document>
|
||||
<paragraph>
|
||||
<image destination=""url"" title="""">
|
||||
<text>foo</text>
|
||||
</image>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"", @"<document>
|
||||
<paragraph>
|
||||
<image destination=""/url"" title="""" />
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"__Applications__ ", @"<document>
|
||||
<paragraph>
|
||||
<strong>
|
||||
<text>Applications</text>
|
||||
</strong>
|
||||
<text> </text>
|
||||
<image destination=""/assets/img/macOS-drag-and-drop.png"" title="""">
|
||||
<text>macOS DMG</text>
|
||||
</image>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_BasicImages_ReturnsImageElement(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
145
Radzen.Blazor.Tests/Markdown/IndentedCodeBlockTests.cs
Normal file
145
Radzen.Blazor.Tests/Markdown/IndentedCodeBlockTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class IndentedCodeBlockTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@" a simple
|
||||
indented code block
|
||||
", @"<document>
|
||||
<code_block>a simple
|
||||
indented code block
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" - foo
|
||||
|
||||
bar", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(
|
||||
@"
|
||||
1. foo
|
||||
|
||||
- bar", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@" <a/>
|
||||
*hi*
|
||||
|
||||
- one", @"<document>
|
||||
<code_block><a/>
|
||||
*hi*
|
||||
|
||||
- one
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" chunk1
|
||||
|
||||
chunk2
|
||||
|
||||
|
||||
|
||||
chunk3", @"<document>
|
||||
<code_block>chunk1
|
||||
|
||||
chunk2
|
||||
|
||||
|
||||
|
||||
chunk3
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" chunk1
|
||||
|
||||
chunk2", @"<document>
|
||||
<code_block>chunk1
|
||||
|
||||
chunk2
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
bar", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" foo
|
||||
bar", @"<document>
|
||||
<code_block>foo
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"# Heading
|
||||
foo
|
||||
Heading
|
||||
------
|
||||
foo
|
||||
----", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>Heading</text>
|
||||
</heading>
|
||||
<code_block>foo
|
||||
</code_block>
|
||||
<heading level=""2"">
|
||||
<text>Heading</text>
|
||||
</heading>
|
||||
<code_block>foo
|
||||
</code_block>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@" foo
|
||||
bar", @"<document>
|
||||
<code_block> foo
|
||||
bar
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"
|
||||
|
||||
foo
|
||||
", @"<document>
|
||||
<code_block>foo
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" foo ", @"<document>
|
||||
<code_block>foo
|
||||
</code_block>
|
||||
</document>")]
|
||||
public void Parse_IndentedCodeBlock(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
852
Radzen.Blazor.Tests/Markdown/LinkTests.cs
Normal file
852
Radzen.Blazor.Tests/Markdown/LinkTests.cs
Normal file
@@ -0,0 +1,852 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class LinkTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[link](/uri \"title\")",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<link destination=""/uri"" title=""title"">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("[link](/uri)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/uri"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("[](./target.md)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""./target.md"" title="""" />
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("[link]()", @"<document>
|
||||
<paragraph>
|
||||
<link destination="""" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("[link](<>)", @"<document>
|
||||
<paragraph>
|
||||
<link destination="""" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("[]()", @"<document>
|
||||
<paragraph>
|
||||
<link destination="""" title="""" />
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_BasicLinks_ReturnsLinkNode(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[link](/my uri)", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>link</text>
|
||||
<text>](/my uri)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("[link](</my uri>)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/my uri"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_LinkDestinationWithSpaces(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link](foo
|
||||
bar)", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>link</text>
|
||||
<text>](foo</text>
|
||||
<softbreak />
|
||||
<text>bar)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
|
||||
public void Parse_LinkDestinationWithNewLines(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[a](<b)c>)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""b)c"" title="""">
|
||||
<text>a</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_LinkDestinationWithCloseParenthesis(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link](<foo\>)", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>link</text>
|
||||
<text>](<foo>)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[a](<b)c
|
||||
[a](<b)c>
|
||||
[a](<b>c)", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>a</text>
|
||||
<text>](<b)c</text>
|
||||
<softbreak />
|
||||
<text>[</text>
|
||||
<text>a</text>
|
||||
<text>](<b)c></text>
|
||||
<softbreak />
|
||||
<text>[</text>
|
||||
<text>a</text>
|
||||
<text>](</text>
|
||||
<html_inline><b></html_inline>
|
||||
<text>c)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_LinkDestinationUnclosedPointyBracket(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link](\(foo\))", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""(foo)"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link](foo\)\:)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""foo):"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link](foo\bar)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""foo\bar"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_Escapes(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link](foo(and(bar)))", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""foo(and(bar))"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link](foo(and(bar))", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>link</text>
|
||||
<text>](foo(and(bar))</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link](foo\(and\(bar\))", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""foo(and(bar)"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link](<foo(and(bar)>)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""foo(and(bar)"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_BallancedParenthesisInLinkDestination(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link](#fragment)
|
||||
[link](https://example.com#fragment)
|
||||
[link](https://example.com?foo=3#frag)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""#fragment"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
<softbreak />
|
||||
<link destination=""https://example.com#fragment"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
<softbreak />
|
||||
<link destination=""https://example.com?foo=3#frag"" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_FragmentAndQueryStringInLinkDestination(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link](""title"")", @"<document>
|
||||
<paragraph>
|
||||
<link destination="""title""" title="""">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_QuotesInDestination(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link](/url ""title"")
|
||||
[link](/url 'title')
|
||||
[link](/url (title))", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title=""title"">
|
||||
<text>link</text>
|
||||
</link>
|
||||
<softbreak />
|
||||
<link destination=""/url"" title=""title"">
|
||||
<text>link</text>
|
||||
</link>
|
||||
<softbreak />
|
||||
<link destination=""/url"" title=""title"">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link](/url ""title ""and"" title"")", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>link</text>
|
||||
<text>](/url ""title ""and"" title"")</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link](/url 'title ""and"" title')", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title=""title "and" title"">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_Title(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link]( /url
|
||||
""title"" )", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title=""title"">
|
||||
<text>link</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link] (/uri)", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>link</text>
|
||||
<text>] (/uri)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_SpacesInLink(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link [foo [bar]]](/uri)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/uri"" title="""">
|
||||
<text>link </text>
|
||||
<text>[</text>
|
||||
<text>foo </text>
|
||||
<text>[</text>
|
||||
<text>bar</text>
|
||||
<text>]</text>
|
||||
<text>]</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link] bar](/uri)", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>link</text>
|
||||
<text>] bar](/uri)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link [bar](/uri)", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>link </text>
|
||||
<link destination=""/uri"" title="""">
|
||||
<text>bar</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[link \[bar](/uri)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/uri"" title="""">
|
||||
<text>link </text>
|
||||
<text>[</text>
|
||||
<text>bar</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_BracketsInText(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[link *foo **bar** `#`*](/uri)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/uri"" title="""">
|
||||
<text>link </text>
|
||||
<emph>
|
||||
<text>foo </text>
|
||||
<strong>
|
||||
<text>bar</text>
|
||||
</strong>
|
||||
<text> </text>
|
||||
<code>#</code>
|
||||
</emph>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[](url)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""url"" title="""">
|
||||
<image destination=""img"" title="""">
|
||||
<text>alt</text>
|
||||
</image>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
|
||||
public void Parse_LinkTextIsInlineContent(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[foo [bar](/uri)](/uri)", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo </text>
|
||||
<link destination=""/uri"" title="""">
|
||||
<text>bar</text>
|
||||
</link>
|
||||
<text>](/uri)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo *[bar [baz](/uri)](/uri)*](/uri)", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo </text>
|
||||
<emph>
|
||||
<text>[</text>
|
||||
<text>bar </text>
|
||||
<link destination=""/uri"" title="""">
|
||||
<text>baz</text>
|
||||
</link>
|
||||
<text>](/uri)</text>
|
||||
</emph>
|
||||
<text>](/uri)</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_NestedLinks(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"*[foo*](/uri)", @"<document>
|
||||
<paragraph>
|
||||
<text>*</text>
|
||||
<link destination=""/uri"" title="""">
|
||||
<text>foo</text>
|
||||
<text>*</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo *bar](baz*)", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""baz*"" title="""">
|
||||
<text>foo </text>
|
||||
<text>*</text>
|
||||
<text>bar</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"*foo [bar* baz]", @"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>foo </text>
|
||||
<text>[</text>
|
||||
<text>bar</text>
|
||||
</emph>
|
||||
<text> baz</text>
|
||||
<text>]</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo`](/uri)`", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<code>](/uri)</code>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_Precedence(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"<http://foo.bar.baz>", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""http://foo.bar.baz"" title="""">
|
||||
<text>http://foo.bar.baz</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<https://foo.bar.baz/test?q=hello&id=22&boolean>", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""https://foo.bar.baz/test?q=hello&id=22&boolean"" title="""">
|
||||
<text>https://foo.bar.baz/test?q=hello&id=22&boolean</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<irc://foo.bar:2233/baz>", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""irc://foo.bar:2233/baz"" title="""">
|
||||
<text>irc://foo.bar:2233/baz</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<MAILTO:FOO@BAR.BAZ>", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""MAILTO:FOO@BAR.BAZ"" title="""">
|
||||
<text>MAILTO:FOO@BAR.BAZ</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<https://foo.bar/baz bim>", @"<document>
|
||||
<paragraph>
|
||||
<text><https://foo.bar/baz bim></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<https://example.com/\[\>", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""https://example.com/\[\"" title="""">
|
||||
<text>https://example.com/\[\</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<foo@bar.example.com>", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""mailto:foo@bar.example.com"" title="""">
|
||||
<text>foo@bar.example.com</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<foo+special@Bar.baz-bar0.com>", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""mailto:foo+special@Bar.baz-bar0.com"" title="""">
|
||||
<text>foo+special@Bar.baz-bar0.com</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<foo\+@bar.example.com>", @"<document>
|
||||
<paragraph>
|
||||
<text><foo+@bar.example.com></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<>", @"<document>
|
||||
<paragraph>
|
||||
<text><></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"< https://foo.bar >", @"<document>
|
||||
<paragraph>
|
||||
<text>< https://foo.bar ></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<m:abc>", @"<document>
|
||||
<paragraph>
|
||||
<text><m:abc></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"<foo.bar.baz>", @"<document>
|
||||
<paragraph>
|
||||
<text><foo.bar.baz></text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"https://example.com", @"<document>
|
||||
<paragraph>
|
||||
<text>https://example.com</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"foo@bar.example.com", @"<document>
|
||||
<paragraph>
|
||||
<text>foo@bar.example.com</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_AutoLink(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"[foo]: /url ""title""
|
||||
|
||||
[foo]", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title=""title"">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" [foo]:
|
||||
/url
|
||||
'the title'
|
||||
|
||||
[foo]", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title=""the title"">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[Foo*bar\]]:my_(url) 'title (with parens)'
|
||||
|
||||
[Foo*bar\]]", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""my_(url)"" title=""title (with parens)"">
|
||||
<text>Foo</text>
|
||||
<text>*</text>
|
||||
<text>bar</text>
|
||||
<text>]</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[Foo bar]:
|
||||
<my url>
|
||||
'title'
|
||||
|
||||
[Foo bar]", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""my url"" title=""title"">
|
||||
<text>Foo bar</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: /url '
|
||||
title
|
||||
line1
|
||||
line2
|
||||
'
|
||||
|
||||
[foo]", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title=""
title
line1
line2
"">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: /url 'title
|
||||
|
||||
with blank line'
|
||||
|
||||
[foo]", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<text>]: /url 'title</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>with blank line'</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<text>]</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]:
|
||||
/url
|
||||
|
||||
[foo]", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title="""">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]:
|
||||
|
||||
[foo]", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<text>]:</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<text>]</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: <>
|
||||
|
||||
[foo]", @"<document>
|
||||
<paragraph>
|
||||
<link destination="""" title="""">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: <bar>(baz)
|
||||
|
||||
[foo]", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<text>]: </text>
|
||||
<html_inline><bar></html_inline>
|
||||
<text>(baz)</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<text>]</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: /url\bar\*baz ""foo\""bar\baz""
|
||||
|
||||
[foo]
|
||||
", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url\bar*baz"" title=""foo\"bar\baz"">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]
|
||||
|
||||
[foo]: url", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""url"" title="""">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]
|
||||
|
||||
[foo]: first
|
||||
[foo]: second", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""first"" title="""">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[FOO]: /url
|
||||
|
||||
[Foo]", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title="""">
|
||||
<text>Foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[ΑΓΩ]: /φου
|
||||
|
||||
[αγω]", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/φου"" title="""">
|
||||
<text>αγω</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: /url", @"<document />")]
|
||||
[InlineData(@"[
|
||||
foo
|
||||
]: /url
|
||||
bar", @"<document>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: /url ""title"" ok", @"<document>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<text>]: /url ""title"" ok</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: /url
|
||||
""title"" ok", @"<document>
|
||||
<paragraph>
|
||||
<text>""title"" ok</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" [foo]: /url ""title""
|
||||
|
||||
[foo]", @"<document>
|
||||
<code_block>[foo]: /url ""title""
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<text>]</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"```
|
||||
[foo]: /url
|
||||
```
|
||||
|
||||
[foo]", @"<document>
|
||||
<code_block>[foo]: /url
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>foo</text>
|
||||
<text>]</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
[bar]: /baz
|
||||
|
||||
[bar]", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<text>[</text>
|
||||
<text>bar</text>
|
||||
<text>]: /baz</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>[</text>
|
||||
<text>bar</text>
|
||||
<text>]</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"# [Foo]
|
||||
[foo]: /url
|
||||
> bar", @"<document>
|
||||
<heading level=""1"">
|
||||
<link destination=""/url"" title="""">
|
||||
<text>Foo</text>
|
||||
</link>
|
||||
</heading>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: /url
|
||||
bar
|
||||
===
|
||||
[foo]", @"<document>
|
||||
<heading level=""1"">
|
||||
<text>bar</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title="""">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: /url
|
||||
===
|
||||
[foo]", @"<document>
|
||||
<paragraph>
|
||||
<text>===</text>
|
||||
<softbreak />
|
||||
<link destination=""/url"" title="""">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]: /foo-url ""foo""
|
||||
[bar]: /bar-url
|
||||
""bar""
|
||||
[baz]: /baz-url
|
||||
|
||||
[foo],
|
||||
[bar],
|
||||
[baz]", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/foo-url"" title=""foo"">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
<text>,</text>
|
||||
<softbreak />
|
||||
<link destination=""/bar-url"" title=""bar"">
|
||||
<text>bar</text>
|
||||
</link>
|
||||
<text>,</text>
|
||||
<softbreak />
|
||||
<link destination=""/baz-url"" title="""">
|
||||
<text>baz</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"[foo]
|
||||
|
||||
> [foo]: /url
|
||||
", @"<document>
|
||||
<paragraph>
|
||||
<link destination=""/url"" title="""">
|
||||
<text>foo</text>
|
||||
</link>
|
||||
</paragraph>
|
||||
<block_quote />
|
||||
</document>")]
|
||||
public void Parse_LinkReference(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
880
Radzen.Blazor.Tests/Markdown/ListItemTests.cs
Normal file
880
Radzen.Blazor.Tests/Markdown/ListItemTests.cs
Normal file
@@ -0,0 +1,880 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class ListItemTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"1. A paragraph
|
||||
with two lines.
|
||||
|
||||
indented code
|
||||
|
||||
> A block quote.", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>A paragraph</text>
|
||||
<softbreak />
|
||||
<text>with two lines.</text>
|
||||
</paragraph>
|
||||
<code_block>indented code
|
||||
</code_block>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>A block quote.</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- one
|
||||
|
||||
two", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>one</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<paragraph>
|
||||
<text>two</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"- one
|
||||
|
||||
two", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>one</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>two</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@" - one
|
||||
|
||||
two", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>one</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<code_block> two
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" - one
|
||||
|
||||
two", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>one</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>two</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@" > > 1. one
|
||||
>>
|
||||
>> two
|
||||
", @"<document>
|
||||
<block_quote>
|
||||
<block_quote>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>one</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>two</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</block_quote>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@">>- one
|
||||
>>
|
||||
> > two
|
||||
", @"<document>
|
||||
<block_quote>
|
||||
<block_quote>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>one</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<paragraph>
|
||||
<text>two</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"-one
|
||||
|
||||
2.two
|
||||
", @"<document>
|
||||
<paragraph>
|
||||
<text>-one</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>2.two</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
|
||||
|
||||
bar", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. foo
|
||||
|
||||
```
|
||||
bar
|
||||
```
|
||||
|
||||
baz
|
||||
|
||||
> bam", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<code_block>bar
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bam</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- Foo
|
||||
|
||||
bar
|
||||
|
||||
|
||||
baz", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
<code_block>bar
|
||||
|
||||
|
||||
baz
|
||||
</code_block>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"123456789. ok", @"<document>
|
||||
<list type=""ordered"" start=""123456789"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>ok</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1234567890. not ok", @"<document>
|
||||
<paragraph>
|
||||
<text>1234567890. not ok</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"0. ok", @"<document>
|
||||
<list type=""ordered"" start=""0"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>ok</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"003. ok", @"<document>
|
||||
<list type=""ordered"" start=""3"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>ok</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"-1. not ok", @"<document>
|
||||
<paragraph>
|
||||
<text>-1. not ok</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
|
||||
bar", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<code_block>bar
|
||||
</code_block>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@" 10. foo
|
||||
|
||||
bar", @"<document>
|
||||
<list type=""ordered"" start=""10"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<code_block>bar
|
||||
</code_block>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. indented code
|
||||
|
||||
paragraph
|
||||
|
||||
more code", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<code_block>indented code
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>paragraph</text>
|
||||
</paragraph>
|
||||
<code_block>more code
|
||||
</code_block>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- indented code
|
||||
|
||||
paragraph
|
||||
|
||||
more code", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<code_block>indented code
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>paragraph</text>
|
||||
</paragraph>
|
||||
<code_block>more code
|
||||
</code_block>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. indented code
|
||||
|
||||
paragraph
|
||||
|
||||
more code", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<code_block> indented code
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>paragraph</text>
|
||||
</paragraph>
|
||||
<code_block>more code
|
||||
</code_block>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
|
||||
bar", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
|
||||
bar", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"-
|
||||
foo
|
||||
-
|
||||
```
|
||||
bar
|
||||
```
|
||||
-
|
||||
baz", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<code_block>bar
|
||||
</code_block>
|
||||
</item>
|
||||
<item>
|
||||
<code_block>baz
|
||||
</code_block>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1.
|
||||
foo", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"-
|
||||
foo", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"-
|
||||
|
||||
foo", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item />
|
||||
</list>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
-
|
||||
- bar", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item />
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
-
|
||||
- bar", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item />
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. foo
|
||||
2.
|
||||
3. bar", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item />
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"*", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item />
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"foo
|
||||
*
|
||||
|
||||
foo
|
||||
1.", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<softbreak />
|
||||
<text>*</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<softbreak />
|
||||
<text>1.</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" 1. A paragraph
|
||||
with two lines.
|
||||
|
||||
indented code
|
||||
|
||||
> A block quote.", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>A paragraph</text>
|
||||
<softbreak />
|
||||
<text>with two lines.</text>
|
||||
</paragraph>
|
||||
<code_block>indented code
|
||||
</code_block>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>A block quote.</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@" 1. A paragraph
|
||||
with two lines.
|
||||
|
||||
indented code
|
||||
|
||||
> A block quote.", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>A paragraph</text>
|
||||
<softbreak />
|
||||
<text>with two lines.</text>
|
||||
</paragraph>
|
||||
<code_block>indented code
|
||||
</code_block>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>A block quote.</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@" 1. A paragraph
|
||||
with two lines.
|
||||
|
||||
indented code
|
||||
|
||||
> A block quote.", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>A paragraph</text>
|
||||
<softbreak />
|
||||
<text>with two lines.</text>
|
||||
</paragraph>
|
||||
<code_block>indented code
|
||||
</code_block>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>A block quote.</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@" 1. A paragraph
|
||||
with two lines.
|
||||
|
||||
indented code
|
||||
|
||||
> A block quote.", @"<document>
|
||||
<code_block>1. A paragraph
|
||||
with two lines.
|
||||
|
||||
indented code
|
||||
|
||||
> A block quote.
|
||||
</code_block>
|
||||
</document>")]
|
||||
public void Parse_ListItem(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@" 1. A paragraph
|
||||
with two lines.
|
||||
|
||||
indented code
|
||||
|
||||
> A block quote.", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>A paragraph</text>
|
||||
<softbreak />
|
||||
<text>with two lines.</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>indented code</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<code_block> > A block quote.
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@" 1. A paragraph
|
||||
with two lines.", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>A paragraph</text>
|
||||
<softbreak />
|
||||
<text>with two lines.</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"> 1. > Blockquote
|
||||
continued here.", @"<document>
|
||||
<block_quote>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>Blockquote</text>
|
||||
<softbreak />
|
||||
<text>continued here.</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</item>
|
||||
</list>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"> 1. > Blockquote
|
||||
> continued here.", @"<document>
|
||||
<block_quote>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>Blockquote</text>
|
||||
<softbreak />
|
||||
<text>continued here.</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</item>
|
||||
</list>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
public void Parse_ListItem_WithLazyContinuation(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"- foo
|
||||
- bar
|
||||
- baz
|
||||
- boo", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>boo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
- bar
|
||||
- baz
|
||||
- boo", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>boo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. foo
|
||||
1. bar
|
||||
1. baz
|
||||
1. boo", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>boo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"
|
||||
1. foo
|
||||
1. bar
|
||||
1. baz
|
||||
1. boo", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>boo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"10) foo
|
||||
- bar", @"<document>
|
||||
<list type=""ordered"" start=""10"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"
|
||||
10) foo
|
||||
- bar", @"<document>
|
||||
<list type=""ordered"" start=""10"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- - foo", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. - foo", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. 1. foo", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- 1. foo", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. - 2. foo", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<list type=""ordered"" start=""2"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- # Foo
|
||||
- Bar
|
||||
---
|
||||
baz", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<heading level=""1"">
|
||||
<text>Foo</text>
|
||||
</heading>
|
||||
</item>
|
||||
<item>
|
||||
<heading level=""2"">
|
||||
<text>Bar</text>
|
||||
</heading>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
public void Parse_ListItem_WithNesting(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
693
Radzen.Blazor.Tests/Markdown/ListTests.cs
Normal file
693
Radzen.Blazor.Tests/Markdown/ListTests.cs
Normal file
@@ -0,0 +1,693 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class ListTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"- foo
|
||||
- bar
|
||||
+ baz", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. foo
|
||||
2. bar
|
||||
3) baz", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<list type=""ordered"" start=""3"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
- bar
|
||||
- baz", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
public void Parse_List(string markdown, string expected)
|
||||
{
|
||||
var actual = ToXml(markdown);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"The number of windows in my house is
|
||||
14. The number of doors is 6.", @"<document>
|
||||
<paragraph>
|
||||
<text>The number of windows in my house is</text>
|
||||
<softbreak />
|
||||
<text>14. The number of doors is 6.</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"The number of windows in my house is
|
||||
1. The number of doors is 6.", @"<document>
|
||||
<paragraph>
|
||||
<text>The number of windows in my house is</text>
|
||||
</paragraph>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>The number of doors is 6.</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
public void Parse_OnlyNumberedListsThatStartWithOneCanInterruptParagraphs(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"- foo
|
||||
|
||||
- bar
|
||||
|
||||
|
||||
- baz", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
- bar
|
||||
- baz
|
||||
|
||||
|
||||
bim
|
||||
", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>bim</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a
|
||||
- b
|
||||
|
||||
- c", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"* a
|
||||
*
|
||||
|
||||
* c", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item />
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a
|
||||
- b
|
||||
|
||||
c
|
||||
- d", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>d</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a
|
||||
- b
|
||||
|
||||
[ref]: /url
|
||||
- d", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>d</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a
|
||||
- ```
|
||||
b
|
||||
|
||||
|
||||
```
|
||||
- c", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<code_block>b
|
||||
|
||||
|
||||
</code_block>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a
|
||||
- b
|
||||
|
||||
c
|
||||
- d", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>d</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"* a
|
||||
> b
|
||||
>
|
||||
* c", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a
|
||||
> b
|
||||
```
|
||||
c
|
||||
```
|
||||
- d
|
||||
", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
<code_block>c
|
||||
</code_block>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>d</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a
|
||||
- b", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. ```
|
||||
foo
|
||||
```
|
||||
|
||||
bar", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<code_block>foo
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"* foo
|
||||
* bar
|
||||
|
||||
baz", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a
|
||||
- b
|
||||
- c
|
||||
|
||||
- d
|
||||
- e
|
||||
- f", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>d</text>
|
||||
</paragraph>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>e</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>f</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
public void Parse_TightAndLooseLists(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"- foo
|
||||
- bar
|
||||
|
||||
<!-- -->
|
||||
|
||||
- baz
|
||||
- bim", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<html_block><!-- --></html_block>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bim</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
|
||||
notcode
|
||||
|
||||
- foo
|
||||
|
||||
<!-- -->
|
||||
|
||||
code", @"<document>
|
||||
<list type=""bullet"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>notcode</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<html_block><!-- --></html_block>
|
||||
<code_block>code
|
||||
</code_block>
|
||||
</document>")]
|
||||
public void Parse_List_Separators(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"- a
|
||||
- b
|
||||
- c
|
||||
- d
|
||||
- e
|
||||
- f
|
||||
- g", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>d</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>e</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>f</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>g</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. a
|
||||
|
||||
2. b
|
||||
|
||||
3. c
|
||||
", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- a
|
||||
- b
|
||||
- c
|
||||
- d
|
||||
- e", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>d</text>
|
||||
<softbreak />
|
||||
<text>- e</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"1. a
|
||||
|
||||
2. b
|
||||
|
||||
3. c", @"<document>
|
||||
<list type=""ordered"" start=""1"" tight=""false"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<code_block>3. c
|
||||
</code_block>
|
||||
</document>")]
|
||||
public void Parse_List_Identation(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BasicNestedLists()
|
||||
{
|
||||
Assert.Equal(@"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>b</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>c</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>", ToXml(@"
|
||||
- a
|
||||
- b
|
||||
- c"));
|
||||
}
|
||||
}
|
||||
109
Radzen.Blazor.Tests/Markdown/ParagraphTests.cs
Normal file
109
Radzen.Blazor.Tests/Markdown/ParagraphTests.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class ParagraphTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
[Fact]
|
||||
public void Parse_BasicParagraph()
|
||||
{
|
||||
Assert.Equal(@"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</document>", ToXml(@"foo"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"aaa
|
||||
|
||||
bbb", @"<document>
|
||||
<paragraph>
|
||||
<text>aaa</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>bbb</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"aaa
|
||||
bbb
|
||||
|
||||
ccc
|
||||
ddd", @"<document>
|
||||
<paragraph>
|
||||
<text>aaa</text>
|
||||
<softbreak />
|
||||
<text>bbb</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>ccc</text>
|
||||
<softbreak />
|
||||
<text>ddd</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"aaa
|
||||
|
||||
|
||||
bbb
|
||||
", @"<document>
|
||||
<paragraph>
|
||||
<text>aaa</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>bbb</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" aaa
|
||||
bbb", @"<document>
|
||||
<paragraph>
|
||||
<text>aaa</text>
|
||||
<softbreak />
|
||||
<text>bbb</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"aaa
|
||||
bbb
|
||||
ccc", @"<document>
|
||||
<paragraph>
|
||||
<text>aaa</text>
|
||||
<softbreak />
|
||||
<text>bbb</text>
|
||||
<softbreak />
|
||||
<text>ccc</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" aaa
|
||||
bbb", @"<document>
|
||||
<paragraph>
|
||||
<text>aaa</text>
|
||||
<softbreak />
|
||||
<text>bbb</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" aaa
|
||||
bbb", @"<document>
|
||||
<code_block>aaa
|
||||
</code_block>
|
||||
<paragraph>
|
||||
<text>bbb</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"aaa
|
||||
bbb ", @"<document>
|
||||
<paragraph>
|
||||
<text>aaa</text>
|
||||
<linebreak />
|
||||
<text>bbb</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_Paragraph(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
41
Radzen.Blazor.Tests/Markdown/SoftLineBreakTests.cs
Normal file
41
Radzen.Blazor.Tests/Markdown/SoftLineBreakTests.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class SoftLineBreakTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"foo
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_SoftLineBreak(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"foo
|
||||
baz", @"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<softbreak />
|
||||
<text>baz</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_SoftLine_RemovesSpacesAtEndAndStartOfLine(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
135
Radzen.Blazor.Tests/Markdown/StrongTests.cs
Normal file
135
Radzen.Blazor.Tests/Markdown/StrongTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class StrongTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("**foo bar**",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<strong>
|
||||
<text>foo bar</text>
|
||||
</strong>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("** foo bar**",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>**</text>
|
||||
<text> foo bar</text>
|
||||
<text>**</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("a**\"foo\"**",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
<text>**</text>
|
||||
<text>""foo""</text>
|
||||
<text>**</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("foo**bar**",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<strong>
|
||||
<text>bar</text>
|
||||
</strong>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("__foo bar__",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<strong>
|
||||
<text>foo bar</text>
|
||||
</strong>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("__ foo bar__",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>__</text>
|
||||
<text> foo bar</text>
|
||||
<text>__</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("__\nfoo bar__",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>__</text>
|
||||
<softbreak />
|
||||
<text>foo bar</text>
|
||||
<text>__</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("a__\"foo\"__",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>a</text>
|
||||
<text>__</text>
|
||||
<text>""foo""</text>
|
||||
<text>__</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("foo__bar__",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
<text>__</text>
|
||||
<text>bar</text>
|
||||
<text>__</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("5__6__78",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>5</text>
|
||||
<text>__</text>
|
||||
<text>6</text>
|
||||
<text>__</text>
|
||||
<text>78</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("пристаням__стремятся__",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>пристаням</text>
|
||||
<text>__</text>
|
||||
<text>стремятся</text>
|
||||
<text>__</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("__foo, __bar__, baz__",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<strong>
|
||||
<text>foo, </text>
|
||||
<strong>
|
||||
<text>bar</text>
|
||||
</strong>
|
||||
<text>, baz</text>
|
||||
</strong>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData("foo-__(bar)__",
|
||||
@"<document>
|
||||
<paragraph>
|
||||
<text>foo-</text>
|
||||
<strong>
|
||||
<text>(bar)</text>
|
||||
</strong>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_StrongEmphasisRules_AdheresToCommonMarkSpec(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
414
Radzen.Blazor.Tests/Markdown/TableTests.cs
Normal file
414
Radzen.Blazor.Tests/Markdown/TableTests.cs
Normal file
@@ -0,0 +1,414 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class TableTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BasicTable()
|
||||
{
|
||||
Assert.Equal(
|
||||
@"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>foo</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>baz</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>bim</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
</document>",
|
||||
ToXml(@"
|
||||
foo|bar
|
||||
--|--
|
||||
baz|bim"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"| foo | bar |
|
||||
| --- | --- |
|
||||
| baz | bim |", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>foo</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>baz</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>bim</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
</document>")]
|
||||
[InlineData(@"| f\|oo |
|
||||
| ------ |
|
||||
| b `\|` az |
|
||||
| b **\|** im |", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>f|oo</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>b </text>
|
||||
<code>|</code>
|
||||
<text> az</text>
|
||||
</cell>
|
||||
</row>
|
||||
<row>
|
||||
<cell>
|
||||
<text>b </text>
|
||||
<strong>
|
||||
<text>|</text>
|
||||
</strong>
|
||||
<text> im</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
</document>")]
|
||||
[InlineData(@"| abc | defghi |
|
||||
:-: | -----------:
|
||||
bar | baz", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell align=""center"">
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
<cell align=""right"">
|
||||
<text>defghi</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell align=""center"">
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
<cell align=""right"">
|
||||
<text>baz</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
</document>")]
|
||||
|
||||
|
||||
[InlineData(@"| abc | def |
|
||||
| --- | --- |
|
||||
bar", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>def</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
<cell />
|
||||
</row>
|
||||
</table>
|
||||
</document>")]
|
||||
[InlineData(@"| abc | def |
|
||||
| --- | --- |
|
||||
c:\\foo", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>def</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>c:</text>
|
||||
<text>\</text>
|
||||
<text>foo</text>
|
||||
</cell>
|
||||
<cell />
|
||||
</row>
|
||||
</table>
|
||||
</document>")]
|
||||
public void Parse_Table(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"| abc | def |
|
||||
| --- | --- |
|
||||
| bar | baz |
|
||||
|
||||
boo", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>def</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>baz</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
<paragraph>
|
||||
<text>boo</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"| foo |
|
||||
| --- |
|
||||
# bar", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>foo</text>
|
||||
</cell>
|
||||
</header>
|
||||
</table>
|
||||
<heading level=""1"">
|
||||
<text>bar</text>
|
||||
</heading>
|
||||
</document>")]
|
||||
[InlineData(@"| foo |
|
||||
| --- |
|
||||
- bar", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>foo</text>
|
||||
</cell>
|
||||
</header>
|
||||
</table>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"| foo |
|
||||
| --- |
|
||||
1. bar", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>foo</text>
|
||||
</cell>
|
||||
</header>
|
||||
</table>
|
||||
<list type=""ordered"" start=""1"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"| abc | def |
|
||||
| --- | --- |
|
||||
| bar | baz |
|
||||
> bar", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>def</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>baz</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
<block_quote>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</block_quote>
|
||||
</document>")]
|
||||
[InlineData(@"| abc | def |
|
||||
| --- | --- |
|
||||
| bar | baz |
|
||||
bar", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>def</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>baz</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
<code_block>bar
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"| abc | def |
|
||||
| --- | --- |
|
||||
| bar | baz |
|
||||
```
|
||||
bar
|
||||
```", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>def</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>baz</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
<code_block>bar
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"| abc | def |
|
||||
| --- | --- |
|
||||
| bar | baz |
|
||||
<div>
|
||||
</div>", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>def</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>baz</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
<html_block><div>
|
||||
</div></html_block>
|
||||
</document>")]
|
||||
[InlineData(@"| abc | def |
|
||||
| --- | --- |
|
||||
| bar | baz |
|
||||
---", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>def</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
<cell>
|
||||
<text>baz</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
public void Parse_Table_AnyBlockOrEmptyLineBreaksTable(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"| abc | def |
|
||||
| --- |", @"<document>
|
||||
<paragraph>
|
||||
<text>| abc | def |</text>
|
||||
<softbreak />
|
||||
<text>| --- |</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
public void Parse_Table_ChecksHeaderAndDelimiter(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"| abc |
|
||||
| --- |
|
||||
| bar | baz |", @"<document>
|
||||
<table>
|
||||
<header>
|
||||
<cell>
|
||||
<text>abc</text>
|
||||
</cell>
|
||||
</header>
|
||||
<row>
|
||||
<cell>
|
||||
<text>bar</text>
|
||||
</cell>
|
||||
</row>
|
||||
</table>
|
||||
</document>")]
|
||||
public void Parse_Table_IgnoresExtraCells(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
162
Radzen.Blazor.Tests/Markdown/ThematicBreakTests.cs
Normal file
162
Radzen.Blazor.Tests/Markdown/ThematicBreakTests.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class ThematicBreakTests
|
||||
{
|
||||
private static string ToXml(string markdown)
|
||||
{
|
||||
var document = MarkdownParser.Parse(markdown);
|
||||
|
||||
return XmlVisitor.ToXml(document);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"***
|
||||
---
|
||||
___", @"<document>
|
||||
<thematic_break />
|
||||
<thematic_break />
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"--
|
||||
**
|
||||
__", @"<document>
|
||||
<paragraph>
|
||||
<text>--</text>
|
||||
<softbreak />
|
||||
<text>**</text>
|
||||
<softbreak />
|
||||
<text>__</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" ***
|
||||
***
|
||||
***", @"<document>
|
||||
<thematic_break />
|
||||
<thematic_break />
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@" ***", @"<document>
|
||||
<code_block>***
|
||||
</code_block>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
***", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
<softbreak />
|
||||
<text>***</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"_____________________________________", @"<document>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"- - -", @"<document>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@" ** * ** * ** * **", @"<document>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"- - - -", @"<document>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"- - - - ", @"<document>
|
||||
<thematic_break />
|
||||
</document>")]
|
||||
[InlineData(@"_ _ _ _ a
|
||||
|
||||
a------
|
||||
|
||||
---a---", @"<document>
|
||||
<paragraph>
|
||||
<text>_</text>
|
||||
<text> </text>
|
||||
<text>_</text>
|
||||
<text> </text>
|
||||
<text>_</text>
|
||||
<text> </text>
|
||||
<text>_</text>
|
||||
<text> a</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>a------</text>
|
||||
</paragraph>
|
||||
<paragraph>
|
||||
<text>---a---</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@" *-*", @"<document>
|
||||
<paragraph>
|
||||
<emph>
|
||||
<text>-</text>
|
||||
</emph>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"- foo
|
||||
***
|
||||
- bar", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<thematic_break />
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"Foo
|
||||
***
|
||||
bar", @"<document>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
<thematic_break />
|
||||
<paragraph>
|
||||
<text>bar</text>
|
||||
</paragraph>
|
||||
</document>")]
|
||||
[InlineData(@"* Foo
|
||||
* * *
|
||||
* Bar", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
<thematic_break />
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>Bar</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
[InlineData(@"- Foo
|
||||
- * * *", @"<document>
|
||||
<list type=""bullet"" tight=""true"">
|
||||
<item>
|
||||
<paragraph>
|
||||
<text>Foo</text>
|
||||
</paragraph>
|
||||
</item>
|
||||
<item>
|
||||
<thematic_break />
|
||||
</item>
|
||||
</list>
|
||||
</document>")]
|
||||
public void Parse_ThematicBreak(string markdown, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ToXml(markdown));
|
||||
}
|
||||
}
|
||||
210
Radzen.Blazor.Tests/Markdown/XmlVisitor.cs
Normal file
210
Radzen.Blazor.Tests/Markdown/XmlVisitor.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
|
||||
namespace Radzen.Blazor.Markdown.Tests;
|
||||
|
||||
public class XmlVisitor : NodeVisitorBase, IDisposable
|
||||
{
|
||||
private readonly XmlWriter writer;
|
||||
|
||||
private XmlVisitor(StringBuilder xml)
|
||||
{
|
||||
writer = XmlWriter.Create(xml, new XmlWriterSettings { OmitXmlDeclaration = true, Indent = true, IndentChars = " ", });
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
writer.Dispose();
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
writer.Close();
|
||||
}
|
||||
|
||||
public static string ToXml(Document document)
|
||||
{
|
||||
var xml = new StringBuilder();
|
||||
|
||||
using var visitor = new XmlVisitor(xml);
|
||||
|
||||
document.Accept(visitor);
|
||||
|
||||
visitor.Close();
|
||||
|
||||
return xml.ToString()!;
|
||||
}
|
||||
|
||||
public override void VisitBlockQuote(BlockQuote blockQuote)
|
||||
{
|
||||
writer.WriteStartElement("block_quote");
|
||||
base.VisitBlockQuote(blockQuote);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitDocument(Document document)
|
||||
{
|
||||
writer.WriteStartDocument();
|
||||
writer.WriteStartElement("document");
|
||||
base.VisitDocument(document);
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndDocument();
|
||||
}
|
||||
|
||||
public override void VisitHeading(Heading heading)
|
||||
{
|
||||
writer.WriteStartElement($"heading");
|
||||
writer.WriteAttributeString("level", heading.Level.ToString());
|
||||
base.VisitHeading(heading);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitListItem(ListItem listItem)
|
||||
{
|
||||
writer.WriteStartElement("item");
|
||||
base.VisitListItem(listItem);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitParagraph(Paragraph paragraph)
|
||||
{
|
||||
writer.WriteStartElement("paragraph");
|
||||
base.VisitParagraph(paragraph);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitUnorderedList(UnorderedList unorderedList)
|
||||
{
|
||||
writer.WriteStartElement("list");
|
||||
writer.WriteAttributeString("type", "bullet");
|
||||
writer.WriteAttributeString("tight", unorderedList.Tight.ToString().ToLowerInvariant());
|
||||
base.VisitUnorderedList(unorderedList);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitText(Text text)
|
||||
{
|
||||
writer.WriteElementString("text", text.Value);
|
||||
}
|
||||
|
||||
public override void VisitOrderedList(OrderedList orderedList)
|
||||
{
|
||||
writer.WriteStartElement("list");
|
||||
writer.WriteAttributeString("type", "ordered");
|
||||
writer.WriteAttributeString("start", orderedList.Start.ToString());
|
||||
writer.WriteAttributeString("tight", orderedList.Tight.ToString().ToLowerInvariant());
|
||||
base.VisitOrderedList(orderedList);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitLink(Link link)
|
||||
{
|
||||
writer.WriteStartElement("link");
|
||||
writer.WriteAttributeString("destination", link.Destination);
|
||||
writer.WriteAttributeString("title", link.Title);
|
||||
base.VisitLink(link);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitImage(Image image)
|
||||
{
|
||||
writer.WriteStartElement("image");
|
||||
writer.WriteAttributeString("destination", image.Destination);
|
||||
writer.WriteAttributeString("title", image.Title);
|
||||
base.VisitImage(image);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitEmphasis(Emphasis emphasis)
|
||||
{
|
||||
writer.WriteStartElement("emph");
|
||||
base.VisitEmphasis(emphasis);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitStrong(Strong strong)
|
||||
{
|
||||
writer.WriteStartElement("strong");
|
||||
base.VisitStrong(strong);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitCode(Code code)
|
||||
{
|
||||
writer.WriteElementString("code", code.Value);
|
||||
}
|
||||
|
||||
public override void VisitHtmlInline(HtmlInline html)
|
||||
{
|
||||
writer.WriteElementString("html_inline", html.Value);
|
||||
}
|
||||
|
||||
public override void VisitLineBreak(LineBreak lineBreak)
|
||||
{
|
||||
writer.WriteElementString("linebreak", string.Empty);
|
||||
}
|
||||
|
||||
public override void VisitSoftLineBreak(SoftLineBreak softLineBreak)
|
||||
{
|
||||
writer.WriteElementString("softbreak", string.Empty);
|
||||
}
|
||||
|
||||
public override void VisitThematicBreak(ThematicBreak thematicBreak)
|
||||
{
|
||||
writer.WriteElementString("thematic_break", string.Empty);
|
||||
}
|
||||
|
||||
public override void VisitIndentedCodeBlock(IndentedCodeBlock codeBlock)
|
||||
{
|
||||
writer.WriteElementString("code_block", codeBlock.Value);
|
||||
}
|
||||
|
||||
public override void VisitFencedCodeBlock(FencedCodeBlock fencedCodeBlock)
|
||||
{
|
||||
writer.WriteStartElement("code_block");
|
||||
if (!string.IsNullOrEmpty(fencedCodeBlock.Info))
|
||||
{
|
||||
writer.WriteAttributeString("info", fencedCodeBlock.Info);
|
||||
}
|
||||
writer.WriteString(fencedCodeBlock.Value);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitHtmlBlock(HtmlBlock htmlBlock)
|
||||
{
|
||||
writer.WriteElementString("html_block", htmlBlock.Value);
|
||||
}
|
||||
|
||||
public override void VisitTable(Table table)
|
||||
{
|
||||
writer.WriteStartElement("table");
|
||||
base.VisitTable(table);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitTableHeaderRow(TableHeaderRow header)
|
||||
{
|
||||
writer.WriteStartElement("header");
|
||||
base.VisitTableHeaderRow(header);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitTableRow(TableRow row)
|
||||
{
|
||||
writer.WriteStartElement("row");
|
||||
base.VisitTableRow(row);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
public override void VisitTableCell(TableCell cell)
|
||||
{
|
||||
writer.WriteStartElement("cell");
|
||||
if (cell.Alignment != TableCellAlignment.None)
|
||||
{
|
||||
writer.WriteAttributeString("align", cell.Alignment.ToString().ToLowerInvariant());
|
||||
}
|
||||
base.VisitTableCell(cell);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ namespace Radzen.Blazor.Tests
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public FieldIdentifier FieldIdentifier => throw new System.NotImplementedException();
|
||||
public FieldIdentifier FieldIdentifier { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
|
||||
|
||||
public object GetValue()
|
||||
{
|
||||
|
||||
@@ -612,10 +612,10 @@ namespace Radzen.Blazor.Tests
|
||||
});
|
||||
});
|
||||
|
||||
component.Find("input").Change("13.53");
|
||||
component.Find("input").Change(13.53);
|
||||
|
||||
var maxDollars = new Dollars(2);
|
||||
Assert.Contains($" value=\"{maxDollars.ToString()}\"", component.Markup);
|
||||
var maxDollars = new Dollars(maxValue);
|
||||
Assert.Contains($" value=\"{maxDollars}\"", component.Markup);
|
||||
Assert.Equal(component.Instance.Value, maxDollars);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using Bunit;
|
||||
using Bunit.TestDoubles;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
@@ -22,6 +23,9 @@ namespace Radzen.Blazor.Tests
|
||||
}
|
||||
|
||||
private static string CreatePanelMenu(string currentAbsoluteUrl, NavLinkMatch match, params string[] urls)
|
||||
=> CreatePanelMenu(currentAbsoluteUrl, match, new Dictionary<string, bool>(urls.Select(url => new KeyValuePair<string, bool>(url, false))));
|
||||
|
||||
private static string CreatePanelMenu(string currentAbsoluteUrl, NavLinkMatch match, Dictionary<string, bool> urls)
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
|
||||
@@ -30,12 +34,13 @@ namespace Radzen.Blazor.Tests
|
||||
|
||||
var component = ctx.RenderComponent<RadzenPanelMenu>();
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.Add(p => p.Match, match).AddChildContent(builder =>
|
||||
component.SetParametersAndRender(parameters => parameters.Add(p => p.Match, match).AddChildContent(builder =>
|
||||
{
|
||||
foreach (var url in urls)
|
||||
{
|
||||
builder.OpenComponent<RadzenPanelMenuItem>(0);
|
||||
builder.AddAttribute(1, nameof(RadzenPanelMenuItem.Path), url);
|
||||
builder.AddAttribute(1, nameof(RadzenPanelMenuItem.Path), url.Key);
|
||||
builder.AddAttribute(2, nameof(RadzenPanelMenuItem.Disabled), url.Value);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
}));
|
||||
@@ -55,6 +60,19 @@ namespace Radzen.Blazor.Tests
|
||||
Assert.Equal(firstIndex, lastIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenPanelMenu_CanDisableMenuItem()
|
||||
{
|
||||
var urls = new Dictionary<string, bool>
|
||||
{
|
||||
{"/datagrid", false},
|
||||
{"/disabled-url", true}
|
||||
};
|
||||
var component = CreatePanelMenu("http://www.example.com/", NavLinkMatch.All, urls);
|
||||
|
||||
Assert.Contains("rz-state-disabled", component);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadzenPanelMenu_MatchesQueryStringParameters()
|
||||
{
|
||||
|
||||
@@ -198,14 +198,10 @@ namespace Radzen.Blazor.Tests
|
||||
});
|
||||
|
||||
Assert.Contains("SummaryContent", component.Markup);
|
||||
Assert.Equal(
|
||||
"display: block",
|
||||
component.Find(".rz-panel-content-summary").ParentElement.Attributes.First(attr => attr.Name == "style").Value
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Panel_DontRenders_SummaryWhenOpen()
|
||||
public void Panel_DoesNotRender_SummaryWhenOpen()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
var component = ctx.RenderComponent<RadzenPanel>();
|
||||
@@ -225,8 +221,8 @@ namespace Radzen.Blazor.Tests
|
||||
|
||||
Assert.Contains("SummaryContent", component.Markup);
|
||||
Assert.Equal(
|
||||
"display: none",
|
||||
component.Find(".rz-panel-content-summary").ParentElement.Attributes.First(attr => attr.Name == "style").Value
|
||||
"true",
|
||||
component.Find(".rz-panel-content-summary").ParentElement.ParentElement.Attributes.First(attr => attr.Name == "aria-hidden").Value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
using AngleSharp.Css;
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Dynamic.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
@@ -116,5 +113,43 @@ namespace Radzen.Blazor.Tests
|
||||
{
|
||||
public List<string> Values { get; set; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProperty_Should_Resolve_DescriptionProperty()
|
||||
{
|
||||
var descriptionProperty = PropertyAccess.GetProperty(typeof(ISimpleInterface), nameof(ISimpleInterface.Description));
|
||||
|
||||
Assert.NotNull(descriptionProperty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProperty_Should_Resolve_NameProperty()
|
||||
{
|
||||
var nameProperty = PropertyAccess.GetProperty(typeof(ISimpleInterface), nameof(ISimpleInterface.Name));
|
||||
|
||||
Assert.NotNull(nameProperty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProperty_Should_Resolve_IdProperty()
|
||||
{
|
||||
var idProperty = PropertyAccess.GetProperty(typeof(ISimpleInterface), nameof(ISimpleBaseInterface.Id));
|
||||
Assert.NotNull(idProperty);
|
||||
}
|
||||
|
||||
interface ISimpleInterface : ISimpleNestedInterface
|
||||
{
|
||||
string Description { get; set; }
|
||||
}
|
||||
|
||||
interface ISimpleNestedInterface : ISimpleBaseInterface
|
||||
{
|
||||
string Name { get; set; }
|
||||
}
|
||||
|
||||
interface ISimpleBaseInterface
|
||||
{
|
||||
int Id { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
158
Radzen.Blazor.Tests/SkeletonTests.cs
Normal file
158
Radzen.Blazor.Tests/SkeletonTests.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using Bunit;
|
||||
using Radzen.Blazor.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
{
|
||||
public class SkeletonTests
|
||||
{
|
||||
[Fact]
|
||||
public void Skeleton_Renders_CssClass()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>();
|
||||
|
||||
Assert.Contains("rz-skeleton", component.Markup);
|
||||
Assert.Contains("rz-skeleton-text", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skeleton_Renders_TypeParameter()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>();
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.Add(p => p.Variant, SkeletonVariant.Circular));
|
||||
|
||||
Assert.Contains("rz-skeleton", component.Markup);
|
||||
Assert.Contains("rz-skeleton-circular", component.Markup);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SkeletonVariant.Text, "rz-skeleton-text")]
|
||||
[InlineData(SkeletonVariant.Circular, "rz-skeleton-circular")]
|
||||
[InlineData(SkeletonVariant.Rectangular, "rz-skeleton-rectangular")]
|
||||
public void Skeleton_Renders_AllTypes(SkeletonVariant type, string expectedClass)
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>(parameters => parameters.Add(p => p.Variant, type));
|
||||
|
||||
Assert.Contains("rz-skeleton", component.Markup);
|
||||
Assert.Contains(expectedClass, component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skeleton_Renders_AnimationParameter()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>();
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.Add(p => p.Animation, SkeletonAnimation.Wave));
|
||||
|
||||
Assert.Contains("rz-skeleton", component.Markup);
|
||||
Assert.Contains("rz-skeleton-wave", component.Markup);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SkeletonAnimation.Wave, "rz-skeleton-wave")]
|
||||
[InlineData(SkeletonAnimation.Pulse, "rz-skeleton-pulse")]
|
||||
public void Skeleton_Renders_AllAnimations(SkeletonAnimation animation, string expectedClass)
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>(parameters => parameters.Add(p => p.Animation, animation));
|
||||
|
||||
Assert.Contains("rz-skeleton", component.Markup);
|
||||
Assert.Contains(expectedClass, component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skeleton_Renders_StyleParameter()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>();
|
||||
|
||||
var style = "width: 200px; height: 20px;";
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.Add(p => p.Style, style));
|
||||
|
||||
Assert.Contains($"style=\"{style}\"", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skeleton_Renders_VisibleParameter()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>();
|
||||
|
||||
// Should be visible by default
|
||||
Assert.Contains("rz-skeleton", component.Markup);
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.Add(p => p.Visible, false));
|
||||
|
||||
// Should not render when not visible
|
||||
Assert.DoesNotContain("rz-skeleton", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skeleton_Renders_UnmatchedParameter()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>();
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.AddUnmatched("data-testid", "skeleton-test"));
|
||||
|
||||
Assert.Contains("data-testid=\"skeleton-test\"", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skeleton_DefaultType_IsText()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>();
|
||||
|
||||
// Should render with text type by default
|
||||
Assert.Contains("rz-skeleton-text", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skeleton_DefaultAnimation_IsNone()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js");
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSkeleton>();
|
||||
|
||||
// Should not render animation classes by default
|
||||
Assert.DoesNotContain("rz-skeleton-wave", component.Markup);
|
||||
Assert.DoesNotContain("rz-skeleton-pulse", component.Markup);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,5 +134,18 @@ namespace Radzen.Blazor.Tests
|
||||
Assert.True(raised);
|
||||
Assert.True(object.Equals(value, !(bool)newValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Switch_Renders_ReadOnlyParameter()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
|
||||
var component = ctx.RenderComponent<RadzenSwitch>();
|
||||
|
||||
component.SetParametersAndRender(parameters => parameters.Add<bool>(p => p.ReadOnly, true));
|
||||
|
||||
Assert.Contains(@$"rz-readonly", component.Markup);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
using Xunit;
|
||||
|
||||
namespace Radzen.Blazor.Tests
|
||||
@@ -29,6 +31,26 @@ namespace Radzen.Blazor.Tests
|
||||
Assert.Contains(@$"value=""{value}""", component.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextboxCanSetFieldIdentifier()
|
||||
{
|
||||
using var ctx = new TestContext();
|
||||
|
||||
var editContext = new EditContext(ctx);
|
||||
var fieldIdentifier = new FieldIdentifier(ctx, nameof(RadzenTextBox.Value));
|
||||
ctx.RenderTree.TryAdd<CascadingValue<EditContext>>(parameters =>
|
||||
{
|
||||
parameters.Add(e => e.Value, editContext);
|
||||
});
|
||||
|
||||
var component = ctx.RenderComponent<RadzenTextBox>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.FieldIdentifier, fieldIdentifier);
|
||||
});
|
||||
|
||||
Assert.Equal(component.Instance.FieldIdentifier, fieldIdentifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextBox_Renders_StyleParameter()
|
||||
{
|
||||
|
||||
1187
Radzen.Blazor.Tests/TimeSpanPickerTests.cs
Normal file
1187
Radzen.Blazor.Tests/TimeSpanPickerTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
460
Radzen.Blazor/AIChatService.cs
Normal file
460
Radzen.Blazor/AIChatService.cs
Normal file
@@ -0,0 +1,460 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.IO;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Linq;
|
||||
|
||||
namespace Radzen;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chat message in the conversation history.
|
||||
/// </summary>
|
||||
public class ChatMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the role of the message sender (system, user, or assistant).
|
||||
/// </summary>
|
||||
public string Role { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content of the message.
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when the message was created.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a conversation session with memory.
|
||||
/// </summary>
|
||||
public class ConversationSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for the conversation session.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of messages in the conversation.
|
||||
/// </summary>
|
||||
public List<ChatMessage> Messages { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when the conversation was created.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when the conversation was last updated.
|
||||
/// </summary>
|
||||
public DateTime LastUpdated { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of messages to keep in memory.
|
||||
/// </summary>
|
||||
public int MaxMessages { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a message to the conversation and manages memory limits.
|
||||
/// </summary>
|
||||
/// <param name="role">The role of the message sender.</param>
|
||||
/// <param name="content">The message content.</param>
|
||||
public void AddMessage(string role, string content)
|
||||
{
|
||||
Messages.Add(new ChatMessage
|
||||
{
|
||||
Role = role,
|
||||
Content = content,
|
||||
Timestamp = DateTime.Now
|
||||
});
|
||||
|
||||
LastUpdated = DateTime.Now;
|
||||
|
||||
// Remove oldest messages if we exceed the limit
|
||||
while (Messages.Count > MaxMessages)
|
||||
{
|
||||
Messages.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all messages from the conversation.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
Messages.Clear();
|
||||
LastUpdated = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation messages formatted for the AI API.
|
||||
/// </summary>
|
||||
/// <param name="systemPrompt">The system prompt to include.</param>
|
||||
/// <returns>A list of message objects for the AI API.</returns>
|
||||
public List<object> GetFormattedMessages(string systemPrompt)
|
||||
{
|
||||
var messages = new List<object>();
|
||||
|
||||
// Add system message
|
||||
messages.Add(new { role = "system", content = systemPrompt });
|
||||
|
||||
// Add conversation messages
|
||||
foreach (var message in Messages)
|
||||
{
|
||||
messages.Add(new { role = message.Role, content = message.Content });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for getting chat completions from an AI model with conversation memory.
|
||||
/// </summary>
|
||||
public interface IAIChatService
|
||||
{
|
||||
/// <summary>
|
||||
/// Streams chat completion responses from the AI model asynchronously with conversation memory.
|
||||
/// </summary>
|
||||
/// <param name="userInput">The user's input message to send to the AI model.</param>
|
||||
/// <param name="sessionId">Optional session ID to maintain conversation context. If null, a new session will be created.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <param name="model">Optional model name to override the configured model.</param>
|
||||
/// <param name="systemPrompt">Optional system prompt to override the configured system prompt.</param>
|
||||
/// <param name="temperature">Optional temperature to override the configured temperature.</param>
|
||||
/// <param name="maxTokens">Optional maximum tokens to override the configured max tokens.</param>
|
||||
/// <returns>An async enumerable that yields streaming response chunks from the AI model.</returns>
|
||||
IAsyncEnumerable<string> GetCompletionsAsync(string userInput, string sessionId = null, CancellationToken cancellationToken = default, string model = null, string systemPrompt = null, double? temperature = null, int? maxTokens = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a conversation session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID. If null, a new session will be created.</param>
|
||||
/// <returns>The conversation session.</returns>
|
||||
ConversationSession GetOrCreateSession(string sessionId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the conversation history for a specific session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID to clear.</param>
|
||||
void ClearSession(string sessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active conversation sessions.
|
||||
/// </summary>
|
||||
/// <returns>A list of active conversation sessions.</returns>
|
||||
IEnumerable<ConversationSession> GetActiveSessions();
|
||||
|
||||
/// <summary>
|
||||
/// Removes old conversation sessions based on age.
|
||||
/// </summary>
|
||||
/// <param name="maxAgeHours">Maximum age in hours for sessions to keep.</param>
|
||||
void CleanupOldSessions(int maxAgeHours = 24);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the <see cref="AIChatService"/>.
|
||||
/// </summary>
|
||||
public class AIChatServiceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the endpoint URL for the AI service.
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the proxy URL for the AI service, if any. If set, this will override the Endpoint.
|
||||
/// </summary>
|
||||
public string Proxy { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API key for authentication with the AI service.
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the header name for the API key (e.g., 'Authorization' or 'api-key').
|
||||
/// </summary>
|
||||
public string ApiKeyHeader { get; set; } = "Authorization";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the model name to use for executing chat completions (e.g., 'gpt-3.5-turbo').
|
||||
/// </summary>
|
||||
public string Model { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the system prompt for the AI assistant.
|
||||
/// </summary>
|
||||
public string SystemPrompt { get; set; } = "You are a helpful AI code assistant.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the temperature for the AI model (0.0 to 2.0). Set to 0.0 for deterministic responses, higher values for more creative outputs.
|
||||
/// </summary>
|
||||
public double Temperature { get; set; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of tokens to generate in the response.
|
||||
/// </summary>
|
||||
public int? MaxTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of messages to keep in conversation memory.
|
||||
/// </summary>
|
||||
public int MaxMessages { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum age in hours for conversation sessions before cleanup.
|
||||
/// </summary>
|
||||
public int SessionMaxAgeHours { get; set; } = 24;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for interacting with AI chat models to get completions with conversation memory.
|
||||
/// </summary>
|
||||
public class AIChatService(IServiceProvider serviceProvider, IOptions<AIChatServiceOptions> options) : IAIChatService
|
||||
{
|
||||
private readonly Dictionary<string, ConversationSession> _sessions = new();
|
||||
private readonly object _sessionsLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration options for the chat streaming service.
|
||||
/// </summary>
|
||||
public AIChatServiceOptions Options => options.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<string> GetCompletionsAsync(string userInput, string sessionId = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default, string model = null, string systemPrompt = null, double? temperature = null, int? maxTokens = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userInput))
|
||||
{
|
||||
throw new ArgumentException("User input cannot be null or empty.", nameof(userInput));
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
var session = GetOrCreateSession(sessionId);
|
||||
|
||||
// Add user message to conversation history
|
||||
session.AddMessage("user", userInput);
|
||||
|
||||
var url = Options.Proxy ?? Options.Endpoint;
|
||||
|
||||
// Get formatted messages including conversation history
|
||||
var messages = session.GetFormattedMessages(systemPrompt ?? Options.SystemPrompt);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
model = model ?? Options.Model,
|
||||
messages = messages,
|
||||
temperature = temperature ?? Options.Temperature,
|
||||
max_tokens = maxTokens ?? Options.MaxTokens,
|
||||
stream = true
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(Options.ApiKey))
|
||||
{
|
||||
if (string.Equals(Options.ApiKeyHeader, "Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Options.ApiKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
request.Headers.Add(Options.ApiKeyHeader, Options.ApiKey);
|
||||
}
|
||||
}
|
||||
|
||||
var httpClient = serviceProvider.GetRequiredService<HttpClient>();
|
||||
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new Exception($"Chat stream failed: {await response.Content.ReadAsStringAsync(cancellationToken)}");
|
||||
}
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var assistantResponse = new StringBuilder();
|
||||
|
||||
while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = line["data:".Length..].Trim();
|
||||
|
||||
if (json == "[DONE]")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var content = ParseStreamingResponse(json);
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
assistantResponse.Append(content);
|
||||
yield return content;
|
||||
}
|
||||
}
|
||||
|
||||
// Add assistant response to conversation history
|
||||
if (assistantResponse.Length > 0)
|
||||
{
|
||||
session.AddMessage("assistant", assistantResponse.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConversationSession GetOrCreateSession(string sessionId = null)
|
||||
{
|
||||
lock (_sessionsLock)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
sessionId = Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
session = new ConversationSession
|
||||
{
|
||||
Id = sessionId,
|
||||
MaxMessages = Options.MaxMessages
|
||||
};
|
||||
_sessions[sessionId] = session;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearSession(string sessionId)
|
||||
{
|
||||
lock (_sessionsLock)
|
||||
{
|
||||
if (_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
session.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ConversationSession> GetActiveSessions()
|
||||
{
|
||||
lock (_sessionsLock)
|
||||
{
|
||||
return _sessions.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CleanupOldSessions(int maxAgeHours = 24)
|
||||
{
|
||||
lock (_sessionsLock)
|
||||
{
|
||||
var cutoffTime = DateTime.Now.AddHours(-maxAgeHours);
|
||||
var sessionsToRemove = _sessions.Values
|
||||
.Where(s => s.LastUpdated < cutoffTime)
|
||||
.Select(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (var sessionId in sessionsToRemove)
|
||||
{
|
||||
_sessions.Remove(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ParseStreamingResponse(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("choices", out var choices) || choices.GetArrayLength() == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var firstChoice = choices[0];
|
||||
|
||||
if (!firstChoice.TryGetProperty("delta", out var delta))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (delta.TryGetProperty("content", out var contentElement))
|
||||
{
|
||||
return contentElement.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring AIChatService in the dependency injection container.
|
||||
/// </summary>
|
||||
public static class AIChatServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the AIChatService to the service collection with the specified configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">The action to configure the AIChatService options.</param>
|
||||
/// <returns>The updated service collection.</returns>
|
||||
public static IServiceCollection AddAIChatService(this IServiceCollection services, Action<AIChatServiceOptions> configureOptions)
|
||||
{
|
||||
if (services == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
if (configureOptions == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configureOptions));
|
||||
}
|
||||
|
||||
services.Configure(configureOptions);
|
||||
services.AddScoped<IAIChatService, AIChatService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the AIChatService to the service collection with default options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The updated service collection.</returns>
|
||||
public static IServiceCollection AddAIChatService(this IServiceCollection services)
|
||||
{
|
||||
services.AddOptions<AIChatServiceOptions>();
|
||||
services.AddScoped<IAIChatService, AIChatService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System.Linq;
|
||||
using System.Linq.Dynamic.Core;
|
||||
using Radzen.Blazor.Rendering;
|
||||
using System.Threading.Tasks;
|
||||
using System.Net.Mime;
|
||||
@@ -410,7 +409,7 @@ namespace Radzen.Blazor
|
||||
|
||||
if (IsDate(CategoryProperty) || IsNumeric(CategoryProperty))
|
||||
{
|
||||
Items = Items.AsQueryable().OrderBy(DynamicLinqCustomTypeProvider.ParsingConfig, CategoryProperty).ToList();
|
||||
Items = Items.AsQueryable().OrderBy(CategoryProperty).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Linq.Dynamic.Core;
|
||||
using System.Linq.Dynamic.Core.Parser;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -293,6 +293,7 @@ namespace Radzen
|
||||
services.AddScoped<TooltipService>();
|
||||
services.AddScoped<ContextMenuService>();
|
||||
services.AddScoped<ThemeService>();
|
||||
services.AddAIChatService();
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -372,6 +373,40 @@ namespace Radzen
|
||||
December = 11,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the time unit of <see cref="TimeSpan"/>.
|
||||
/// </summary>
|
||||
public enum TimeSpanUnit
|
||||
{
|
||||
/// <summary>
|
||||
/// Day.
|
||||
/// </summary>
|
||||
Day = 0,
|
||||
/// <summary>
|
||||
/// Hour.
|
||||
/// </summary>
|
||||
Hour = 1,
|
||||
/// <summary>
|
||||
/// Minute.
|
||||
/// </summary>
|
||||
Minute = 2,
|
||||
/// <summary>
|
||||
/// Second.
|
||||
/// </summary>
|
||||
Second = 3,
|
||||
/// <summary>
|
||||
/// Millisecond.
|
||||
/// </summary>
|
||||
Millisecond = 4
|
||||
#if NET7_0_OR_GREATER
|
||||
,
|
||||
/// <summary>
|
||||
/// Microsecond.
|
||||
/// </summary>
|
||||
Microsecond = 5
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Html editor mode (Rendered or Raw). Also used for toolbar buttons to enable/disable according to mode.
|
||||
/// </summary>
|
||||
@@ -734,9 +769,14 @@ namespace Radzen
|
||||
public AppointmentData Appointment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time span.
|
||||
/// Gets or sets the time span which represents the difference between slot start and appointment start.
|
||||
/// </summary>
|
||||
public TimeSpan TimeSpan { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date of the slot where the appointment is moved.
|
||||
/// </summary>
|
||||
public DateTime SlotDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1052,6 +1092,21 @@ namespace Radzen
|
||||
/// </summary>
|
||||
public class PreviewFileInfo : FileInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of PreviewFileInfo from a browser file.
|
||||
/// </summary>
|
||||
/// <param name="files"></param>
|
||||
public PreviewFileInfo(IBrowserFile files) : base(files)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new, empty instance of PreviewFileInfo.
|
||||
/// </summary>
|
||||
public PreviewFileInfo()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URL of the previewed file.
|
||||
/// </summary>
|
||||
@@ -1063,11 +1118,41 @@ namespace Radzen
|
||||
/// </summary>
|
||||
public class Query
|
||||
{
|
||||
Func<string> _getFilter;
|
||||
internal Func<string> GetFilter
|
||||
{
|
||||
get
|
||||
{
|
||||
return _getFilter;
|
||||
}
|
||||
set
|
||||
{
|
||||
_filter = null;
|
||||
_getFilter = value;
|
||||
}
|
||||
}
|
||||
|
||||
string _filter;
|
||||
/// <summary>
|
||||
/// Gets or sets the filter.
|
||||
/// Gets the filter expression as a string.
|
||||
/// </summary>
|
||||
/// <value>The filter.</value>
|
||||
public string Filter { get; set; }
|
||||
public string Filter
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_filter == null && GetFilter != null)
|
||||
{
|
||||
_filter = GetFilter();
|
||||
}
|
||||
return _filter;
|
||||
}
|
||||
set
|
||||
{
|
||||
_filter = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the filter expression as a collection of filter descriptors.
|
||||
/// </summary>
|
||||
@@ -2288,6 +2373,19 @@ namespace Radzen
|
||||
/// </summary>
|
||||
/// <value>The property.</value>
|
||||
public string Property { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the property type.
|
||||
/// </summary>
|
||||
/// <value>The property type.</value>
|
||||
[JsonIgnore]
|
||||
public Type Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the filtered property.
|
||||
/// </summary>
|
||||
/// <value>The property.</value>
|
||||
public string FilterProperty { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the value to filter by.
|
||||
/// </summary>
|
||||
@@ -2326,6 +2424,18 @@ namespace Radzen
|
||||
/// <value>The property.</value>
|
||||
public string Property { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the property type.
|
||||
/// </summary>
|
||||
/// <value>The property type.</value>
|
||||
public Type Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the filtered property.
|
||||
/// </summary>
|
||||
/// <value>The property.</value>
|
||||
public string FilterProperty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value to filter by.
|
||||
/// </summary>
|
||||
@@ -2429,6 +2539,43 @@ namespace Radzen
|
||||
public int Level { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The result of a call to a <see cref="QueryableExtension"/>.GroupByMany() overload.
|
||||
/// </summary>
|
||||
public class GroupResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The key value of the group.
|
||||
/// </summary>
|
||||
public dynamic Key { get; internal set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The number of resulting elements in the group.
|
||||
/// </summary>
|
||||
public int Count { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// The resulting elements in the group.
|
||||
/// </summary>
|
||||
public IEnumerable Items { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// The resulting subgroups in the group.
|
||||
/// </summary>
|
||||
public IEnumerable<GroupResult> Subgroups { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <see cref="System.String" /> showing the key of the group and the number of items in the group.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A <see cref="System.String" /> that represents this instance.
|
||||
/// </returns>
|
||||
public override string ToString()
|
||||
{
|
||||
return string.Format(CultureInfo.CurrentCulture, "{0} ({1})", ((object)Key).ToString(), Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supplies information about a <see cref="PagedDataBoundComponent{TItem}.LoadData" /> event that is being raised.
|
||||
/// </summary>
|
||||
@@ -2447,11 +2594,43 @@ namespace Radzen
|
||||
/// Gets the sort expression as a string.
|
||||
/// </summary>
|
||||
public string OrderBy { get; set; }
|
||||
|
||||
Func<string> _getFilter;
|
||||
internal Func<string> GetFilter
|
||||
{
|
||||
get
|
||||
{
|
||||
return _getFilter;
|
||||
}
|
||||
set
|
||||
{
|
||||
_filter = null;
|
||||
_getFilter = value;
|
||||
}
|
||||
}
|
||||
|
||||
string _filter;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the filter expression as a string.
|
||||
/// </summary>
|
||||
/// <value>The filter.</value>
|
||||
public string Filter { get; set; }
|
||||
public string Filter
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_filter == null && GetFilter != null)
|
||||
{
|
||||
_filter = GetFilter();
|
||||
}
|
||||
return _filter;
|
||||
}
|
||||
set
|
||||
{
|
||||
_filter = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the filter expression as a collection of filter descriptors.
|
||||
/// </summary>
|
||||
@@ -2521,6 +2700,12 @@ namespace Radzen
|
||||
/// <value>The filter.</value>
|
||||
public string Filter { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets filter property used to limit and distinct values, if not set, args.Data are used as values.
|
||||
/// </summary>
|
||||
/// <value>The filter property.</value>
|
||||
public string Property { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the column.
|
||||
/// </summary>
|
||||
@@ -2959,7 +3144,9 @@ namespace Radzen
|
||||
{
|
||||
if (propertyName.Contains("["))
|
||||
{
|
||||
return DynamicExpressionParser.ParseLambda<TItem, TValue>(null, false, propertyName).Compile();
|
||||
var arg = Expression.Parameter(typeof(TItem));
|
||||
|
||||
return Expression.Lambda<Func<TItem, TValue>>(QueryableExtension.GetNestedPropertyExpression(arg, propertyName, type), arg).Compile();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -3100,23 +3287,7 @@ namespace Radzen
|
||||
/// <param name="property">The property.</param>
|
||||
public static string GetProperty(string property)
|
||||
{
|
||||
Type type = null;
|
||||
try
|
||||
{
|
||||
type = Type.GetType($"System.{property}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore the exception and assume the property start without a type and do not need the '@' prefix
|
||||
}
|
||||
var propertyName = $"{(type != null ? "@" : "")}{property}";
|
||||
|
||||
if (propertyName.IndexOf(".") != -1)
|
||||
{
|
||||
return $"np({propertyName})";
|
||||
}
|
||||
|
||||
return propertyName;
|
||||
return property;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3333,6 +3504,32 @@ namespace Radzen
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the property by its name. If the type is an interface, it will search through all interfaces implemented by the type.
|
||||
/// </summary>
|
||||
/// <param name="type">The type.</param>
|
||||
/// <param name="property">The property.</param>
|
||||
/// <returns>PropertyInfo.</returns>
|
||||
public static PropertyInfo GetProperty(Type type, string property)
|
||||
{
|
||||
if (type.IsInterface)
|
||||
{
|
||||
var interfaces = type.GetInterfaces();
|
||||
|
||||
foreach (var @interface in interfaces)
|
||||
{
|
||||
var propertyInfo = @interface.GetProperty(property);
|
||||
|
||||
if (propertyInfo != null)
|
||||
{
|
||||
return propertyInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return type.GetProperty(property);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dynamic property expression when binding to IDictionary.
|
||||
/// </summary>
|
||||
@@ -3345,7 +3542,7 @@ namespace Radzen
|
||||
var typeName = isEnum ? "Enum" : (Nullable.GetUnderlyingType(type) ?? type).Name;
|
||||
var typeFunc = $@"{typeName}{(!isEnum && Nullable.GetUnderlyingType(type) != null ? "?" : "")}";
|
||||
|
||||
return $@"{typeFunc}(it[""{name}""])";
|
||||
return $@"({typeFunc})it[""{name}""]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3405,7 +3602,7 @@ namespace Radzen
|
||||
/// Gets the field identifier.
|
||||
/// </summary>
|
||||
/// <value>The field identifier.</value>
|
||||
FieldIdentifier FieldIdentifier { get; }
|
||||
FieldIdentifier FieldIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sets the focus.
|
||||
@@ -3810,4 +4007,42 @@ namespace Radzen
|
||||
/// </summary>
|
||||
Right
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skeleton shape variants.
|
||||
/// </summary>
|
||||
public enum SkeletonVariant
|
||||
{
|
||||
/// <summary>
|
||||
/// Text skeleton shape.
|
||||
/// </summary>
|
||||
Text,
|
||||
/// <summary>
|
||||
/// Circular skeleton shape.
|
||||
/// </summary>
|
||||
Circular,
|
||||
/// <summary>
|
||||
/// Rectangular skeleton shape.
|
||||
/// </summary>
|
||||
Rectangular
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skeleton animation types.
|
||||
/// </summary>
|
||||
public enum SkeletonAnimation
|
||||
{
|
||||
/// <summary>
|
||||
/// No animation.
|
||||
/// </summary>
|
||||
None,
|
||||
/// <summary>
|
||||
/// Wave animation.
|
||||
/// </summary>
|
||||
Wave,
|
||||
/// <summary>
|
||||
/// Pulse animation.
|
||||
/// </summary>
|
||||
Pulse
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,25 @@ using Microsoft.JSInterop;
|
||||
|
||||
namespace Radzen
|
||||
{
|
||||
/// <summary>
|
||||
/// Specifies the SameSite attribute for the cookie.
|
||||
/// </summary>
|
||||
public enum CookieSameSiteMode
|
||||
{
|
||||
/// <summary>
|
||||
/// No SameSite attribute.
|
||||
/// </summary>
|
||||
None,
|
||||
/// <summary>
|
||||
/// Lax SameSite attribute.
|
||||
/// </summary>
|
||||
Lax,
|
||||
/// <summary>
|
||||
/// Strict SameSite attribute.
|
||||
/// </summary>
|
||||
Strict
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for the <see cref="CookieThemeService" />.
|
||||
/// </summary>
|
||||
@@ -21,6 +40,16 @@ namespace Radzen
|
||||
/// Gets or sets the cookie duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; set; } = TimeSpan.FromDays(365);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use secure cookies.
|
||||
/// </summary>
|
||||
public bool IsSecure { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SameSite attribute for the cookie.
|
||||
/// </summary>
|
||||
public CookieSameSiteMode? SameSite { get; set; } = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -75,8 +104,19 @@ namespace Radzen
|
||||
private void OnThemeChanged()
|
||||
{
|
||||
var expiration = DateTime.Now.Add(options.Duration);
|
||||
var cookie = $"{options.Name}={themeService.Theme}; expires={expiration:R}; path=/";
|
||||
|
||||
_ = jsRuntime.InvokeVoidAsync("eval", $"document.cookie = \"{options.Name}={themeService.Theme}; expires={expiration:R}; path=/\"");
|
||||
if (options.SameSite.HasValue)
|
||||
{
|
||||
cookie += $"; SameSite={options.SameSite}";
|
||||
}
|
||||
|
||||
if (options.IsSecure)
|
||||
{
|
||||
cookie += "; Secure";
|
||||
}
|
||||
|
||||
_ = jsRuntime.InvokeVoidAsync("eval", $"document.cookie = \"{cookie}\"");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,9 @@ using Microsoft.AspNetCore.Components.Forms;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Radzen.Blazor;
|
||||
using Radzen.Blazor.Rendering;
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Dynamic.Core;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -106,13 +103,13 @@ namespace Radzen
|
||||
/// <summary>
|
||||
/// The value
|
||||
/// </summary>
|
||||
object _value;
|
||||
private T _value = default;
|
||||
/// <summary>
|
||||
/// Gets or sets the value.
|
||||
/// </summary>
|
||||
/// <value>The value.</value>
|
||||
[Parameter]
|
||||
public object Value
|
||||
public T Value
|
||||
{
|
||||
get
|
||||
{
|
||||
@@ -120,10 +117,14 @@ namespace Radzen
|
||||
}
|
||||
set
|
||||
{
|
||||
if (_value != value)
|
||||
if (value == null || value.Equals("null"))
|
||||
{
|
||||
_value = object.Equals(value, "null") ? null : value;
|
||||
_value = default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value.Equals(_value))
|
||||
_value = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +189,7 @@ namespace Radzen
|
||||
if (_data != value)
|
||||
{
|
||||
_view = null;
|
||||
_value = null;
|
||||
_value = default;
|
||||
_data = value;
|
||||
StateHasChanged();
|
||||
}
|
||||
@@ -262,23 +263,7 @@ namespace Radzen
|
||||
{
|
||||
if (!string.IsNullOrEmpty(searchText))
|
||||
{
|
||||
var ignoreCase = FilterCaseSensitivity == FilterCaseSensitivity.CaseInsensitive;
|
||||
|
||||
var query = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(TextProperty))
|
||||
{
|
||||
query.Add(TextProperty);
|
||||
}
|
||||
|
||||
if (ignoreCase)
|
||||
{
|
||||
query.Add("ToLower()");
|
||||
}
|
||||
|
||||
query.Add($"{Enum.GetName(typeof(StringFilterOperator), FilterOperator)}(@0)");
|
||||
|
||||
_view = Query.Where(DynamicLinqCustomTypeProvider.ParsingConfig, string.Join(".", query), ignoreCase ? searchText.ToLower() : searchText);
|
||||
_view = Query.Where(TextProperty, searchText, FilterOperator, FilterCaseSensitivity);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -290,6 +275,8 @@ namespace Radzen
|
||||
}
|
||||
}
|
||||
|
||||
internal IEnumerable GetView() => View;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the edit context.
|
||||
/// </summary>
|
||||
@@ -301,7 +288,8 @@ namespace Radzen
|
||||
/// Gets the field identifier.
|
||||
/// </summary>
|
||||
/// <value>The field identifier.</value>
|
||||
public FieldIdentifier FieldIdentifier { get; private set; }
|
||||
[Parameter]
|
||||
public FieldIdentifier FieldIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value expression.
|
||||
@@ -316,26 +304,25 @@ namespace Radzen
|
||||
/// <returns>A Task representing the asynchronous operation.</returns>
|
||||
public override async Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
var searchTextChanged = parameters.DidParameterChange(nameof(SearchText), SearchText);
|
||||
if (searchTextChanged)
|
||||
{
|
||||
searchText = parameters.GetValueOrDefault<string>(SearchText);
|
||||
}
|
||||
|
||||
// check for changes before setting the properties through the base call
|
||||
var dataChanged = parameters.DidParameterChange(nameof(Data), Data);
|
||||
var disabledChanged = parameters.DidParameterChange(nameof(Disabled), Disabled);
|
||||
|
||||
// allow the base class to process parameters and set the properties
|
||||
// after this call the parameters object should be considered stale
|
||||
await base.SetParametersAsync(parameters);
|
||||
|
||||
// handle changes
|
||||
if (dataChanged)
|
||||
{
|
||||
await OnDataChanged();
|
||||
}
|
||||
|
||||
var disabledChanged = parameters.DidParameterChange(nameof(Disabled), Disabled);
|
||||
|
||||
var result = base.SetParametersAsync(parameters);
|
||||
|
||||
if (EditContext != null && ValueExpression != null && FieldIdentifier.Model != EditContext.Model)
|
||||
if (EditContext != null && (ValueExpression != null || ValueChanged.HasDelegate) && FieldIdentifier.Model != EditContext.Model)
|
||||
{
|
||||
FieldIdentifier = FieldIdentifier.Create(ValueExpression);
|
||||
FieldIdentifier = ValueExpression != null
|
||||
? FieldIdentifier.Create(ValueExpression)
|
||||
: FieldIdentifier.Create(() => Value);
|
||||
EditContext.OnValidationStateChanged -= ValidationStateChanged;
|
||||
EditContext.OnValidationStateChanged += ValidationStateChanged;
|
||||
}
|
||||
@@ -344,8 +331,6 @@ namespace Radzen
|
||||
{
|
||||
FormFieldContext?.DisabledChanged(Disabled);
|
||||
}
|
||||
|
||||
await result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Dynamic.Core;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Radzen
|
||||
@@ -213,6 +212,13 @@ namespace Radzen
|
||||
[Parameter]
|
||||
public bool Multiple { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating the selected index should reset to the top item when filtering, resulting in a down arrow action will start moving from the top.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> to reset selected index to -1 when filtering; otherwise, <c>false</c>.</value>
|
||||
[Parameter]
|
||||
public bool ResetSelectedIndexOnFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user can select all values in multiple selection. Set to <c>true</c> by default.
|
||||
/// </summary>
|
||||
@@ -277,6 +283,18 @@ namespace Radzen
|
||||
/// The selected item
|
||||
/// </summary>
|
||||
protected object selectedItem = null;
|
||||
Type GetItemType(IEnumerable items)
|
||||
{
|
||||
var firstType = items.Cast<object>().FirstOrDefault()?.GetType() ?? typeof(object);
|
||||
var hasNull = items.Cast<object>().Where(i => i == null).Any();
|
||||
|
||||
if (hasNull && firstType.IsValueType && Nullable.GetUnderlyingType(firstType) == null)
|
||||
{
|
||||
return typeof(Nullable<>).MakeGenericType(firstType);
|
||||
}
|
||||
|
||||
return firstType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects all.
|
||||
@@ -300,37 +318,18 @@ namespace Radzen
|
||||
|
||||
if (!string.IsNullOrEmpty(ValueProperty))
|
||||
{
|
||||
System.Reflection.PropertyInfo pi = PropertyAccess.GetElementType(Data.GetType()).GetProperty(ValueProperty);
|
||||
var elementType = PropertyAccess.GetElementType(Data.GetType());
|
||||
System.Reflection.PropertyInfo pi = PropertyAccess.GetProperty(elementType, ValueProperty);
|
||||
internalValue = selectedItems.Select(i => GetItemOrValueFromProperty(i, ValueProperty)).AsQueryable().Cast(pi.PropertyType);
|
||||
}
|
||||
else
|
||||
{
|
||||
var type = typeof(T).IsGenericType ? typeof(T).GetGenericArguments()[0] : typeof(T);
|
||||
var type = typeof(T).IsGenericType ? typeof(T).GetGenericArguments()[0] :
|
||||
QueryableExtension.IsEnumerable(typeof(T)) ? GetItemType(selectedItems) : typeof(T);
|
||||
internalValue = selectedItems.AsQueryable().Cast(type);
|
||||
}
|
||||
|
||||
if (typeof(IList).IsAssignableFrom(typeof(T)))
|
||||
{
|
||||
var list = (IList)Activator.CreateInstance(typeof(T));
|
||||
foreach (var i in (IEnumerable)internalValue)
|
||||
{
|
||||
list.Add(i);
|
||||
}
|
||||
await ValueChanged.InvokeAsync((T)(object)list);
|
||||
}
|
||||
else if (typeof(T).IsGenericType && typeof(ICollection<>).MakeGenericType(typeof(T).GetGenericArguments()[0]).IsAssignableFrom(typeof(T)))
|
||||
{
|
||||
var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(typeof(T).GetGenericArguments()[0]));
|
||||
foreach (var i in (IEnumerable)internalValue)
|
||||
{
|
||||
list.Add(i);
|
||||
}
|
||||
await ValueChanged.InvokeAsync((T)(object)list);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ValueChanged.InvokeAsync((T)internalValue);
|
||||
}
|
||||
await collectionAssignment.MakeAssignment((IEnumerable)internalValue, ValueChanged);
|
||||
if (FieldIdentifier.FieldName != null) { EditContext?.NotifyFieldChanged(FieldIdentifier); }
|
||||
await Change.InvokeAsync(internalValue);
|
||||
|
||||
@@ -341,15 +340,17 @@ namespace Radzen
|
||||
|
||||
internal bool IsAllSelected()
|
||||
{
|
||||
List<object> notDisabledItemsInList = View.Cast<object>().ToList()
|
||||
.Where(i => disabledPropertyGetter == null || disabledPropertyGetter(i) as bool? != true)
|
||||
.ToList();
|
||||
|
||||
if (LoadData.HasDelegate && !string.IsNullOrEmpty(ValueProperty))
|
||||
{
|
||||
return View != null && View.Cast<object>().ToList()
|
||||
.Where(i => disabledPropertyGetter != null ? disabledPropertyGetter(i) as bool? != true : true)
|
||||
return View != null && notDisabledItemsInList.Count > 0 && notDisabledItemsInList
|
||||
.All(i => IsItemSelectedByValue(GetItemOrValueFromProperty(i, ValueProperty)));
|
||||
}
|
||||
|
||||
return View != null && selectedItems.Count == View.Cast<object>().ToList()
|
||||
.Where(i => disabledPropertyGetter != null ? disabledPropertyGetter(i) as bool? != true : true).Count();
|
||||
return View != null && notDisabledItemsInList.Count > 0 && selectedItems.Count == notDisabledItemsInList.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -373,7 +374,7 @@ namespace Radzen
|
||||
await SearchTextChanged.InvokeAsync(searchText);
|
||||
await JSRuntime.InvokeAsync<string>("Radzen.setInputValue", search, "");
|
||||
|
||||
internalValue = default(T);
|
||||
internalValue = collectionAssignment.GetCleared();
|
||||
selectedItem = null;
|
||||
|
||||
selectedItems.Clear();
|
||||
@@ -434,7 +435,7 @@ namespace Radzen
|
||||
|
||||
var type = query.ElementType;
|
||||
|
||||
if (type == typeof(object) && typeof(EnumerableQuery).IsAssignableFrom(query.GetType()) && query.Any())
|
||||
if (type == typeof(object) && typeof(EnumerableQuery).IsAssignableFrom(query.GetType()) && query.Cast<object>().Any())
|
||||
{
|
||||
var firstElement = query.Cast<object>().FirstOrDefault(i => i != null);
|
||||
if (firstElement != null)
|
||||
@@ -628,7 +629,7 @@ namespace Radzen
|
||||
/// <param name="shouldSelectOnChange">Should select item on item change with keyboard.</param>
|
||||
protected virtual async System.Threading.Tasks.Task HandleKeyPress(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs args, bool isFilter = false, bool? shouldSelectOnChange = null)
|
||||
{
|
||||
if (Disabled)
|
||||
if (Disabled || Data == null)
|
||||
return;
|
||||
|
||||
List<object> items = Enumerable.Empty<object>().ToList();
|
||||
@@ -678,7 +679,7 @@ namespace Radzen
|
||||
//
|
||||
}
|
||||
}
|
||||
else if (key == "Enter" || key == "NumpadEnter")
|
||||
else if (key == "Enter" || key == "NumpadEnter" || (key == "Space" && !isFilter))
|
||||
{
|
||||
preventKeydown = true;
|
||||
|
||||
@@ -698,11 +699,14 @@ namespace Radzen
|
||||
|
||||
if (!popupOpened)
|
||||
{
|
||||
await OpenPopup(key, isFilter);
|
||||
if(key != "Space")
|
||||
{
|
||||
await OpenPopup(key, isFilter);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!Multiple)
|
||||
if (!Multiple && !isFilter)
|
||||
{
|
||||
await ClosePopup(key);
|
||||
}
|
||||
@@ -716,6 +720,8 @@ namespace Radzen
|
||||
}
|
||||
else if (key == "Escape" || key == "Tab")
|
||||
{
|
||||
preventKeydown = false;
|
||||
|
||||
await ClosePopup(key);
|
||||
}
|
||||
else if (key == "Delete" && AllowClear)
|
||||
@@ -737,15 +743,20 @@ namespace Radzen
|
||||
{
|
||||
preventKeydown = true;
|
||||
|
||||
if (ResetSelectedIndexOnFilter)
|
||||
{
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
Debounce(DebounceFilter, FilterDelay);
|
||||
}
|
||||
else
|
||||
else if (args.Key.Length == 1 && !args.CtrlKey && !args.AltKey && !args.ShiftKey)
|
||||
{
|
||||
// searching for element
|
||||
var filteredItems = (!string.IsNullOrEmpty(TextProperty) ?
|
||||
Query.Where(TextProperty, args.Key, StringFilterOperator.StartsWith, FilterCaseSensitivity.CaseInsensitive) :
|
||||
Query)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
.Cast(Query.ElementType).Cast<dynamic>().ToList();
|
||||
|
||||
|
||||
if (previousKey != args.Key)
|
||||
@@ -757,7 +768,7 @@ namespace Radzen
|
||||
itemIndex = itemIndex + 1 >= filteredItems.Count() ? 0 : itemIndex + 1;
|
||||
var itemToSelect = filteredItems.ElementAtOrDefault(itemIndex);
|
||||
|
||||
if (itemToSelect != null)
|
||||
if (itemToSelect is not null)
|
||||
{
|
||||
if (!Multiple)
|
||||
{
|
||||
@@ -921,33 +932,38 @@ namespace Radzen
|
||||
/// <returns>A Task representing the asynchronous operation.</returns>
|
||||
public override async Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
// check for changes before setting the properties through the base call
|
||||
var selectedItemChanged = parameters.DidParameterChange(nameof(SelectedItem), SelectedItem);
|
||||
var visibleChanged = parameters.DidParameterChange(nameof(Visible), Visible);
|
||||
var valueChanged = parameters.DidParameterChange(nameof(Value), Value);
|
||||
|
||||
// manually set properties that are not set through the base call
|
||||
if (valueChanged)
|
||||
{
|
||||
internalValue = parameters.GetValueOrDefault<object>(nameof(Value));
|
||||
if (PreserveCollectionOnSelection)
|
||||
{
|
||||
collectionAssignment = new ReferenceGenericCollectionAssignment((T)internalValue);
|
||||
}
|
||||
}
|
||||
|
||||
var pageSize = parameters.GetValueOrDefault<int>(nameof(PageSize));
|
||||
if (pageSize != default(int))
|
||||
{
|
||||
PageSize = pageSize;
|
||||
}
|
||||
|
||||
// allow the base class to process parameters and set the properties
|
||||
// after this call the parameters object should be considered stale
|
||||
await base.SetParametersAsync(parameters);
|
||||
|
||||
var selectedItemChanged = parameters.DidParameterChange(nameof(SelectedItem), SelectedItem);
|
||||
// handle changes
|
||||
if (selectedItemChanged)
|
||||
{
|
||||
await SelectItem(selectedItem, false);
|
||||
}
|
||||
|
||||
var shouldClose = false;
|
||||
|
||||
if (parameters.DidParameterChange(nameof(Visible), Visible))
|
||||
{
|
||||
var visible = parameters.GetValueOrDefault<bool>(nameof(Visible));
|
||||
shouldClose = !visible;
|
||||
}
|
||||
|
||||
if (parameters.DidParameterChange(nameof(Value), Value))
|
||||
{
|
||||
internalValue = parameters.GetValueOrDefault<object>(nameof(Value));
|
||||
}
|
||||
|
||||
await base.SetParametersAsync(parameters);
|
||||
|
||||
var shouldClose = visibleChanged && !Visible;
|
||||
if (shouldClose && !firstRender)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("Radzen.destroyPopup", PopupID);
|
||||
@@ -1097,6 +1113,12 @@ namespace Radzen
|
||||
}
|
||||
|
||||
internal object internalValue;
|
||||
|
||||
/// <summary>
|
||||
/// Will add/remove selected items from a bound ICollection<T>, instead of replacing it.
|
||||
/// </summary>
|
||||
protected bool PreserveCollectionOnSelection = false;
|
||||
private DefaultCollectionAssignment collectionAssignment = new();
|
||||
|
||||
/// <summary>
|
||||
/// Selects the item.
|
||||
@@ -1135,7 +1157,8 @@ namespace Radzen
|
||||
|
||||
if (!string.IsNullOrEmpty(ValueProperty))
|
||||
{
|
||||
System.Reflection.PropertyInfo pi = PropertyAccess.GetElementType(Data.GetType()).GetProperty(ValueProperty);
|
||||
var elementType = PropertyAccess.GetElementType(Data.GetType());
|
||||
System.Reflection.PropertyInfo pi = PropertyAccess.GetProperty(elementType, ValueProperty);
|
||||
internalValue = selectedItems.Select(i => GetItemOrValueFromProperty(i, ValueProperty)).AsQueryable().Cast(pi.PropertyType);
|
||||
}
|
||||
else
|
||||
@@ -1143,7 +1166,7 @@ namespace Radzen
|
||||
var query = Data.AsQueryable();
|
||||
var elementType = query.ElementType;
|
||||
|
||||
if (elementType == typeof(object) && typeof(EnumerableQuery).IsAssignableFrom(query.GetType()) && query.Any())
|
||||
if (elementType == typeof(object) && typeof(EnumerableQuery).IsAssignableFrom(query.GetType()) && query.Cast<object>().Any())
|
||||
{
|
||||
var firstElement = query.Cast<object>().FirstOrDefault(i => i != null);
|
||||
if (firstElement != null)
|
||||
@@ -1166,41 +1189,13 @@ namespace Radzen
|
||||
{
|
||||
if (ValueChanged.HasDelegate)
|
||||
{
|
||||
if (typeof(IList).IsAssignableFrom(typeof(T)))
|
||||
if (Multiple)
|
||||
{
|
||||
if (object.Equals(internalValue, null))
|
||||
{
|
||||
await ValueChanged.InvokeAsync(default(T));
|
||||
}
|
||||
else
|
||||
{
|
||||
var list = (IList)Activator.CreateInstance(typeof(T));
|
||||
foreach (var i in (IEnumerable)internalValue)
|
||||
{
|
||||
list.Add(i);
|
||||
}
|
||||
await ValueChanged.InvokeAsync((T)(object)list);
|
||||
}
|
||||
}
|
||||
else if (typeof(T).IsGenericType && typeof(ICollection<>).MakeGenericType(typeof(T).GetGenericArguments()[0]).IsAssignableFrom(typeof(T)))
|
||||
{
|
||||
if (object.Equals(internalValue, null))
|
||||
{
|
||||
await ValueChanged.InvokeAsync(default(T));
|
||||
}
|
||||
else
|
||||
{
|
||||
var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(typeof(T).GetGenericArguments()[0]));
|
||||
foreach (var i in (IEnumerable)internalValue)
|
||||
{
|
||||
list.Add(i);
|
||||
}
|
||||
await ValueChanged.InvokeAsync((T)(object)list);
|
||||
}
|
||||
await collectionAssignment.MakeAssignment((IEnumerable)internalValue, ValueChanged);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ValueChanged.InvokeAsync(object.Equals(internalValue, null) ? default(T) : (T)internalValue);
|
||||
await ValueChanged.InvokeAsync((T)internalValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1260,7 +1255,16 @@ namespace Radzen
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedItem = view.AsQueryable().Where(DynamicLinqCustomTypeProvider.ParsingConfig, $@"{ValueProperty} == @0", value).FirstOrDefault();
|
||||
SelectedItem = view.AsQueryable().Where(new FilterDescriptor[]
|
||||
{
|
||||
new FilterDescriptor()
|
||||
{
|
||||
Property = ValueProperty,
|
||||
FilterValue = value
|
||||
}
|
||||
},
|
||||
LogicalFilterOperator.And,
|
||||
FilterCaseSensitivity.Default).FirstOrDefault();
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -1277,7 +1281,7 @@ namespace Radzen
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ValueProperty))
|
||||
{
|
||||
foreach (object v in values.ToDynamicList())
|
||||
foreach (object v in values.Cast<dynamic>().ToList())
|
||||
{
|
||||
dynamic item;
|
||||
|
||||
@@ -1287,7 +1291,16 @@ namespace Radzen
|
||||
}
|
||||
else
|
||||
{
|
||||
item = view.AsQueryable().Where(DynamicLinqCustomTypeProvider.ParsingConfig, $@"{ValueProperty} == @0", v).FirstOrDefault();
|
||||
item = view.AsQueryable().Where(new FilterDescriptor[]
|
||||
{
|
||||
new FilterDescriptor()
|
||||
{
|
||||
Property = ValueProperty,
|
||||
FilterValue = v
|
||||
}
|
||||
},
|
||||
LogicalFilterOperator.And,
|
||||
FilterCaseSensitivity.Default).FirstOrDefault();
|
||||
}
|
||||
|
||||
if (!object.Equals(item, null) && !selectedItems.AsQueryable().Where(i => object.Equals(GetItemOrValueFromProperty(i, ValueProperty), v)).Any())
|
||||
@@ -1300,7 +1313,6 @@ namespace Radzen
|
||||
{
|
||||
selectedItems = values.Cast<object>().ToHashSet(ItemComparer);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1337,5 +1349,121 @@ namespace Radzen
|
||||
|
||||
keys.Clear();
|
||||
}
|
||||
|
||||
private class DefaultCollectionAssignment
|
||||
{
|
||||
public virtual async Task MakeAssignment(IEnumerable selectedItems, EventCallback<T> valueChanged)
|
||||
{
|
||||
if (typeof(IList).IsAssignableFrom(typeof(T)))
|
||||
{
|
||||
if (object.Equals(selectedItems, null))
|
||||
{
|
||||
await valueChanged.InvokeAsync(default(T));
|
||||
}
|
||||
else
|
||||
{
|
||||
var list = (IList)Activator.CreateInstance(typeof(T));
|
||||
foreach (var i in (IEnumerable)selectedItems)
|
||||
{
|
||||
list.Add(i);
|
||||
}
|
||||
await valueChanged.InvokeAsync((T)(object)list);
|
||||
}
|
||||
}
|
||||
else if (typeof(T).IsGenericType && typeof(ICollection<>).MakeGenericType(typeof(T).GetGenericArguments()[0]).IsAssignableFrom(typeof(T)))
|
||||
{
|
||||
if (object.Equals(selectedItems, null))
|
||||
{
|
||||
await valueChanged.InvokeAsync(default(T));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(typeof(T).GetGenericArguments()[0]));
|
||||
foreach (var i in (IEnumerable)selectedItems)
|
||||
{
|
||||
list.Add(i);
|
||||
}
|
||||
await valueChanged.InvokeAsync((T)(object)list);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await valueChanged.InvokeAsync(object.Equals(selectedItems, null) ? default(T) : (T)selectedItems);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual T GetCleared()
|
||||
{
|
||||
return default(T);
|
||||
}
|
||||
}
|
||||
|
||||
private class ReferenceGenericCollectionAssignment : DefaultCollectionAssignment
|
||||
{
|
||||
private readonly T originalCollection;
|
||||
private readonly bool canHandle;
|
||||
private readonly System.Reflection.MethodInfo clearMethod;
|
||||
private readonly System.Reflection.MethodInfo addMethod;
|
||||
private readonly System.Reflection.MethodInfo removeMethod;
|
||||
|
||||
public ReferenceGenericCollectionAssignment(T originalCollection)
|
||||
{
|
||||
this.originalCollection = originalCollection;
|
||||
// Pre-calculate if we can handle this instance and get method info
|
||||
if (originalCollection != null)
|
||||
{
|
||||
var actualType = originalCollection.GetType();
|
||||
if (actualType.IsGenericType && !actualType.IsArray)
|
||||
{
|
||||
var elementType = actualType.GetGenericArguments()[0];
|
||||
var genericCollectionType = typeof(ICollection<>).MakeGenericType(elementType);
|
||||
|
||||
if (genericCollectionType.IsAssignableFrom(actualType))
|
||||
{
|
||||
clearMethod = actualType.GetMethod("Clear");
|
||||
addMethod = actualType.GetMethod("Add");
|
||||
removeMethod = typeof(T).GetMethod("Remove");
|
||||
canHandle = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task MakeAssignment(IEnumerable selectedItems, EventCallback<T> valueChanged)
|
||||
{
|
||||
if (!canHandle)
|
||||
{
|
||||
// Fallback to default behavior when we can't handle the type
|
||||
await base.MakeAssignment(selectedItems, valueChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentItems = selectedItems.Cast<object>().ToHashSet();
|
||||
var existingItems = ((IEnumerable)originalCollection).Cast<object>().ToHashSet();
|
||||
foreach (var i in currentItems)
|
||||
{
|
||||
if (!existingItems.Contains(i))
|
||||
addMethod.Invoke(originalCollection, [i]);
|
||||
}
|
||||
foreach (var i in existingItems)
|
||||
{
|
||||
if (!currentItems.Contains(i))
|
||||
removeMethod.Invoke(originalCollection, [i]);
|
||||
}
|
||||
|
||||
await valueChanged.InvokeAsync(originalCollection);
|
||||
}
|
||||
|
||||
public override T GetCleared()
|
||||
{
|
||||
if (canHandle)
|
||||
{
|
||||
clearMethod.Invoke(originalCollection, null);
|
||||
return originalCollection;
|
||||
}
|
||||
return base.GetCleared();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
134
Radzen.Blazor/DynamicExtensions.cs
Normal file
134
Radzen.Blazor/DynamicExtensions.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using Radzen;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace System.Linq.Dynamic.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Class DynamicExtensions used to replace System.Linq.Dynamic.Core library.
|
||||
/// </summary>
|
||||
public static class DynamicExtensions
|
||||
{
|
||||
static readonly Func<string, Type> typeLocator = type => AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(a => a.GetTypes()).FirstOrDefault(t => t.FullName.Replace("+", ".") == type);
|
||||
|
||||
/// <summary>
|
||||
/// Filters using the specified filter descriptors.
|
||||
/// </summary>
|
||||
public static IQueryable<T> Where<T>(
|
||||
this IQueryable<T> source,
|
||||
string predicate,
|
||||
object[] parameters = null, object[] otherParameters = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (parameters != null && !string.IsNullOrEmpty(predicate))
|
||||
{
|
||||
predicate = Regex.Replace(predicate, @"@(\d+)", match =>
|
||||
{
|
||||
int index = int.Parse(match.Groups[1].Value);
|
||||
if (index >= parameters.Length)
|
||||
throw new InvalidOperationException($"No parameter provided for {match.Value}");
|
||||
|
||||
return ExpressionSerializer.FormatValue(parameters[index]);
|
||||
});
|
||||
}
|
||||
|
||||
predicate = (predicate == "true" ? "" : predicate)
|
||||
.Replace("DateTime(", "DateTime.Parse(")
|
||||
.Replace("DateTimeOffset(", "DateTimeOffset.Parse(")
|
||||
.Replace("DateOnly(", "DateOnly.Parse(")
|
||||
.Replace("Guid(", "Guid.Parse(")
|
||||
.Replace(" = ", " == ");
|
||||
|
||||
return !string.IsNullOrEmpty(predicate) ?
|
||||
source.Where(ExpressionParser.ParsePredicate<T>(predicate, typeLocator)) : source;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid predicate: {predicate}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the elements of a sequence in ascending or descending order according to a key.
|
||||
/// </summary>
|
||||
public static IOrderedQueryable<T> OrderBy<T>(
|
||||
this IQueryable<T> source,
|
||||
string selector,
|
||||
object[] parameters = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return QueryableExtension.OrderBy(source, selector);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid selector: {selector}.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects each element of a sequence into a collection of property values.
|
||||
/// </summary>
|
||||
public static IQueryable Select<T>(this IQueryable<T> source, string selector, object[] parameters = null)
|
||||
{
|
||||
if (source.ElementType == typeof(object))
|
||||
{
|
||||
var elementType = source.ElementType;
|
||||
|
||||
if (source.Expression is MethodCallExpression methodCall && methodCall.Method.Name == "Cast")
|
||||
{
|
||||
elementType = methodCall.Arguments[0].Type.GetGenericArguments().FirstOrDefault() ?? typeof(object);
|
||||
}
|
||||
else if (typeof(EnumerableQuery).IsAssignableFrom(source.GetType()))
|
||||
{
|
||||
elementType = source.FirstOrDefault()?.GetType() ?? typeof(object);
|
||||
}
|
||||
|
||||
return source.Cast(elementType).Select(selector, expression => ExpressionParser.ParseLambda(expression, elementType));
|
||||
}
|
||||
|
||||
return source.Select(selector, expression => ExpressionParser.ParseLambda<T>(expression));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects each element of a sequence into a collection of property values.
|
||||
/// </summary>
|
||||
public static IQueryable Select(this IQueryable source, string selector, object[] parameters = null)
|
||||
{
|
||||
return source.Select(selector, expression => ExpressionParser.ParseLambda(expression, source.ElementType));
|
||||
}
|
||||
|
||||
private static IQueryable Select(this IQueryable source, string selector, Func<string, LambdaExpression> lambdaCreator)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(selector))
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
if (!selector.Contains("=>"))
|
||||
{
|
||||
var properties = selector
|
||||
.Replace("new (", "").Replace(")", "").Replace("new {", "").Replace("}", "").Trim()
|
||||
.Split(",", StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
selector = string.Join(", ", properties
|
||||
.Select(s => (s.Contains(" as ") ? s.Split(" as ").LastOrDefault().Trim().Replace(".", "_") : s.Trim().Replace(".", "_")) +
|
||||
" = " + $"it.{s.Split(" as ").FirstOrDefault().Replace(".", "?.").Trim()}"));
|
||||
}
|
||||
|
||||
var lambda = lambdaCreator(selector.Contains("=>") ? selector : $"it => new {{ {selector} }}");
|
||||
|
||||
return source.Provider.CreateQuery(Expression.Call(typeof(Queryable), nameof(Queryable.Select),
|
||||
[source.ElementType, lambda.Body.Type], source.Expression, Expression.Quote(lambda)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid selector: {selector}.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq.Dynamic.Core;
|
||||
using System.Linq.Dynamic.Core.CustomTypeProviders;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Radzen.Blazor
|
||||
{
|
||||
class DynamicLinqCustomTypeProvider : IDynamicLinqCustomTypeProvider
|
||||
{
|
||||
static readonly HashSet<Type> empty = [];
|
||||
public HashSet<Type> GetCustomTypes() => empty;
|
||||
public Dictionary<Type, List<MethodInfo>> GetExtensionMethods() => new Dictionary<Type, List<MethodInfo>>();
|
||||
public Type ResolveType(string typeName) => throw new NotSupportedException();
|
||||
public Type ResolveTypeBySimpleName(string simpleTypeName) => throw new NotSupportedException();
|
||||
public static ParsingConfig ParsingConfig = new() { CustomTypeProvider = new DynamicLinqCustomTypeProvider(), AllowEqualsAndToStringMethodsOnObject = true };
|
||||
}
|
||||
}
|
||||
56
Radzen.Blazor/DynamicTypeFactory.cs
Normal file
56
Radzen.Blazor/DynamicTypeFactory.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Reflection.Emit;
|
||||
|
||||
static class DynamicTypeFactory
|
||||
{
|
||||
public static Type CreateType(string typeName, string[] propertyNames, Type[] propertyTypes)
|
||||
{
|
||||
if (propertyNames.Length != propertyTypes.Length)
|
||||
{
|
||||
throw new ArgumentException("Property names and types count mismatch.");
|
||||
}
|
||||
|
||||
var assemblyName = new AssemblyName("DynamicTypesAssembly");
|
||||
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
|
||||
var moduleBuilder = assemblyBuilder.DefineDynamicModule("DynamicTypesModule");
|
||||
|
||||
var typeBuilder = moduleBuilder.DefineType(typeName, TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.Sealed);
|
||||
|
||||
for (int i = 0; i < propertyNames.Length; i++)
|
||||
{
|
||||
var fieldBuilder = typeBuilder.DefineField("_" + propertyNames[i], propertyTypes[i], FieldAttributes.Private);
|
||||
var propertyBuilder = typeBuilder.DefineProperty(propertyNames[i], PropertyAttributes.None, propertyTypes[i], null);
|
||||
|
||||
var getterMethod = typeBuilder.DefineMethod(
|
||||
"get_" + propertyNames[i],
|
||||
MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig,
|
||||
propertyTypes[i],
|
||||
Type.EmptyTypes);
|
||||
|
||||
var getterIl = getterMethod.GetILGenerator();
|
||||
getterIl.Emit(OpCodes.Ldarg_0);
|
||||
getterIl.Emit(OpCodes.Ldfld, fieldBuilder);
|
||||
getterIl.Emit(OpCodes.Ret);
|
||||
|
||||
propertyBuilder.SetGetMethod(getterMethod);
|
||||
|
||||
var setterMethod = typeBuilder.DefineMethod(
|
||||
"set_" + propertyNames[i],
|
||||
MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig,
|
||||
null,
|
||||
[propertyTypes[i]]);
|
||||
|
||||
var setterIl = setterMethod.GetILGenerator();
|
||||
setterIl.Emit(OpCodes.Ldarg_0);
|
||||
setterIl.Emit(OpCodes.Ldarg_1);
|
||||
setterIl.Emit(OpCodes.Stfld, fieldBuilder);
|
||||
setterIl.Emit(OpCodes.Ret);
|
||||
|
||||
propertyBuilder.SetSetMethod(setterMethod);
|
||||
}
|
||||
|
||||
var dynamicType = typeBuilder.CreateType();
|
||||
return dynamicType;
|
||||
}
|
||||
}
|
||||
873
Radzen.Blazor/ExpressionLexer.cs
Normal file
873
Radzen.Blazor/ExpressionLexer.cs
Normal file
@@ -0,0 +1,873 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text;
|
||||
|
||||
namespace Radzen;
|
||||
|
||||
class Token
|
||||
{
|
||||
public TokenType Type { get; set; }
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public ValueKind ValueKind { get; set; } = ValueKind.None;
|
||||
public int IntValue { get; internal set; }
|
||||
public uint UintValue { get; internal set; }
|
||||
public long LongValue { get; internal set; }
|
||||
public ulong UlongValue { get; internal set; }
|
||||
public decimal DecimalValue { get; internal set; }
|
||||
public float FloatValue { get; internal set; }
|
||||
public double DoubleValue { get; internal set; }
|
||||
|
||||
public Token(TokenType type)
|
||||
{
|
||||
Type = type;
|
||||
}
|
||||
|
||||
public Token(TokenType type, string value)
|
||||
{
|
||||
Type = type;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public ConstantExpression ToConstantExpression()
|
||||
{
|
||||
return ValueKind switch
|
||||
{
|
||||
ValueKind.Null => Expression.Constant(null),
|
||||
ValueKind.String => Expression.Constant(Value),
|
||||
ValueKind.Character => Expression.Constant(Value[0]),
|
||||
ValueKind.Int => Expression.Constant(IntValue),
|
||||
ValueKind.UInt => Expression.Constant(UintValue),
|
||||
ValueKind.Long => Expression.Constant(LongValue),
|
||||
ValueKind.ULong => Expression.Constant(UlongValue),
|
||||
ValueKind.Float => Expression.Constant(FloatValue),
|
||||
ValueKind.Double => Expression.Constant(DoubleValue),
|
||||
ValueKind.Decimal => Expression.Constant(DecimalValue),
|
||||
ValueKind.True => Expression.Constant(true),
|
||||
ValueKind.False => Expression.Constant(false),
|
||||
_ => throw new InvalidOperationException($"Unsupported value kind: {ValueKind}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum ValueKind
|
||||
{
|
||||
None,
|
||||
String,
|
||||
Int,
|
||||
Float,
|
||||
Double,
|
||||
Decimal,
|
||||
Character,
|
||||
Null,
|
||||
True,
|
||||
False,
|
||||
Long,
|
||||
UInt,
|
||||
ULong,
|
||||
}
|
||||
|
||||
|
||||
enum TokenType
|
||||
{
|
||||
None,
|
||||
Identifier,
|
||||
EqualsEquals,
|
||||
NotEquals,
|
||||
EqualsGreaterThan,
|
||||
StringLiteral,
|
||||
NumericLiteral,
|
||||
Dot,
|
||||
OpenParen,
|
||||
CloseParen,
|
||||
Comma,
|
||||
AmpersandAmpersand,
|
||||
Ampersand,
|
||||
BarBar,
|
||||
Bar,
|
||||
GreaterThan,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
GreaterThanOrEqual,
|
||||
Plus,
|
||||
Minus,
|
||||
Star,
|
||||
Slash,
|
||||
CharacterLiteral,
|
||||
QuestionMark,
|
||||
QuestionMarkQuestionMark,
|
||||
Colon,
|
||||
QuestionDot,
|
||||
New,
|
||||
NullLiteral,
|
||||
TrueLiteral,
|
||||
FalseLiteral,
|
||||
OpenBracket,
|
||||
CloseBracket,
|
||||
OpenBrace,
|
||||
CloseBrace,
|
||||
ExclamationMark,
|
||||
Equals,
|
||||
Caret,
|
||||
GreaterThanGreaterThan,
|
||||
LessThanLessThan,
|
||||
}
|
||||
|
||||
static class TokenTypeExtensions
|
||||
{
|
||||
public static ExpressionType ToExpressionType(this TokenType tokenType)
|
||||
{
|
||||
return tokenType switch
|
||||
{
|
||||
TokenType.EqualsEquals => ExpressionType.Equal,
|
||||
TokenType.NotEquals => ExpressionType.NotEqual,
|
||||
TokenType.EqualsGreaterThan => ExpressionType.GreaterThanOrEqual,
|
||||
TokenType.AmpersandAmpersand => ExpressionType.AndAlso,
|
||||
TokenType.Ampersand => ExpressionType.And,
|
||||
TokenType.BarBar => ExpressionType.OrElse,
|
||||
TokenType.Bar => ExpressionType.Or,
|
||||
TokenType.GreaterThan => ExpressionType.GreaterThan,
|
||||
TokenType.LessThan => ExpressionType.LessThan,
|
||||
TokenType.LessThanOrEqual => ExpressionType.LessThanOrEqual,
|
||||
TokenType.GreaterThanOrEqual => ExpressionType.GreaterThanOrEqual,
|
||||
TokenType.Plus => ExpressionType.Add,
|
||||
TokenType.Minus => ExpressionType.Subtract,
|
||||
TokenType.Star => ExpressionType.Multiply,
|
||||
TokenType.Slash => ExpressionType.Divide,
|
||||
TokenType.Caret => ExpressionType.ExclusiveOr,
|
||||
TokenType.GreaterThanGreaterThan => ExpressionType.RightShift,
|
||||
TokenType.LessThanLessThan => ExpressionType.LeftShift,
|
||||
_ => throw new InvalidOperationException($"Unsupported token type: {tokenType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ExpressionLexer(string expression)
|
||||
{
|
||||
private int position;
|
||||
|
||||
private char Peek(int offset = 0)
|
||||
{
|
||||
if (position + offset >= expression.Length)
|
||||
{
|
||||
return '\0';
|
||||
}
|
||||
|
||||
return expression[position + offset];
|
||||
}
|
||||
|
||||
private void Advance(int count)
|
||||
{
|
||||
position += count;
|
||||
}
|
||||
|
||||
bool TryAdvance(char expected)
|
||||
{
|
||||
if (Peek(1) == expected)
|
||||
{
|
||||
Advance(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ScanTrivia()
|
||||
{
|
||||
while (char.IsWhiteSpace(Peek()))
|
||||
{
|
||||
Advance(1);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Token> Scan(string expression)
|
||||
{
|
||||
var lexer = new ExpressionLexer(expression);
|
||||
|
||||
return [.. lexer.Scan()];
|
||||
}
|
||||
|
||||
public IEnumerable<Token> Scan()
|
||||
{
|
||||
while (position < expression.Length)
|
||||
{
|
||||
ScanTrivia();
|
||||
|
||||
var token = ScanToken();
|
||||
|
||||
if (token.Type == TokenType.None)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return token;
|
||||
}
|
||||
|
||||
yield return new Token(TokenType.None, string.Empty);
|
||||
}
|
||||
|
||||
private Token ScanToken()
|
||||
{
|
||||
var ch = Peek();
|
||||
|
||||
switch (ch)
|
||||
{
|
||||
case '"':
|
||||
return ScanStringLiteral();
|
||||
case '\'':
|
||||
return ScanCharacterLiteral();
|
||||
case '=':
|
||||
if (TryAdvance('='))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.EqualsEquals);
|
||||
}
|
||||
|
||||
if (TryAdvance('>'))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.EqualsGreaterThan);
|
||||
}
|
||||
|
||||
Advance(1);
|
||||
return new Token(TokenType.Equals);
|
||||
case '!':
|
||||
if (TryAdvance('='))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.NotEquals);
|
||||
}
|
||||
Advance(1);
|
||||
return new Token(TokenType.ExclamationMark);
|
||||
case '>':
|
||||
if (TryAdvance('='))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.GreaterThanOrEqual);
|
||||
}
|
||||
if (TryAdvance('>'))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.GreaterThanGreaterThan);
|
||||
}
|
||||
Advance(1);
|
||||
return new Token(TokenType.GreaterThan);
|
||||
case '<':
|
||||
if (TryAdvance('<'))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.LessThanLessThan);
|
||||
}
|
||||
if (TryAdvance('='))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.LessThanOrEqual);
|
||||
}
|
||||
Advance(1);
|
||||
return new Token(TokenType.LessThan);
|
||||
case '+':
|
||||
Advance(1);
|
||||
return new Token(TokenType.Plus);
|
||||
case '-':
|
||||
Advance(1);
|
||||
return new Token(TokenType.Minus);
|
||||
case '*':
|
||||
Advance(1);
|
||||
return new Token(TokenType.Star);
|
||||
case '/':
|
||||
Advance(1);
|
||||
return new Token(TokenType.Slash);
|
||||
case '.':
|
||||
Advance(1);
|
||||
return new Token(TokenType.Dot);
|
||||
case '(':
|
||||
Advance(1);
|
||||
return new Token(TokenType.OpenParen);
|
||||
case ')':
|
||||
Advance(1);
|
||||
return new Token(TokenType.CloseParen);
|
||||
case '[':
|
||||
Advance(1);
|
||||
return new Token(TokenType.OpenBracket);
|
||||
case ']':
|
||||
Advance(1);
|
||||
return new Token(TokenType.CloseBracket);
|
||||
case '{':
|
||||
Advance(1);
|
||||
return new Token(TokenType.OpenBrace);
|
||||
case '}':
|
||||
Advance(1);
|
||||
return new Token(TokenType.CloseBrace);
|
||||
case ',':
|
||||
Advance(1);
|
||||
return new Token(TokenType.Comma);
|
||||
case '&':
|
||||
if (TryAdvance('&'))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.AmpersandAmpersand);
|
||||
}
|
||||
Advance(1);
|
||||
return new Token(TokenType.Ampersand);
|
||||
case '|':
|
||||
if (TryAdvance('|'))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.BarBar);
|
||||
}
|
||||
Advance(1);
|
||||
return new Token(TokenType.Bar);
|
||||
case '?':
|
||||
if (TryAdvance('.'))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.QuestionDot);
|
||||
}
|
||||
|
||||
if (TryAdvance('?'))
|
||||
{
|
||||
Advance(1);
|
||||
return new Token(TokenType.QuestionMarkQuestionMark);
|
||||
}
|
||||
|
||||
Advance(1);
|
||||
return new Token(TokenType.QuestionMark);
|
||||
case ':':
|
||||
Advance(1);
|
||||
return new Token(TokenType.Colon);
|
||||
case '^':
|
||||
Advance(1);
|
||||
return new Token(TokenType.Caret);
|
||||
case >= '0' and <= '9':
|
||||
return ScanNumericLiteral();
|
||||
case '_':
|
||||
case (>= 'a' and <= 'z') or (>= 'A' and <= 'Z'):
|
||||
var token = ScanIdentifier();
|
||||
|
||||
return token.Value switch
|
||||
{
|
||||
"null" => new Token(TokenType.NullLiteral) { ValueKind = ValueKind.Null },
|
||||
"true" => new Token(TokenType.TrueLiteral) { ValueKind = ValueKind.True },
|
||||
"false" => new Token(TokenType.FalseLiteral) { ValueKind = ValueKind.False },
|
||||
"new" => new Token(TokenType.New),
|
||||
_ => token
|
||||
};
|
||||
}
|
||||
|
||||
return new Token(TokenType.None, string.Empty);
|
||||
}
|
||||
|
||||
private char ScanEscapeSequence()
|
||||
{
|
||||
var ch = Peek();
|
||||
|
||||
Advance(1);
|
||||
|
||||
switch (ch)
|
||||
{
|
||||
case '\'':
|
||||
case '"':
|
||||
case '\\':
|
||||
break;
|
||||
case '0':
|
||||
ch = '\u0000';
|
||||
break;
|
||||
case 'a':
|
||||
ch = '\u0007';
|
||||
break;
|
||||
case 'b':
|
||||
ch = '\u0008';
|
||||
break;
|
||||
case 'f':
|
||||
ch = '\u000c';
|
||||
break;
|
||||
case 'n':
|
||||
ch = '\u000a';
|
||||
break;
|
||||
case 'r':
|
||||
ch = '\u000d';
|
||||
break;
|
||||
case 't':
|
||||
ch = '\u0009';
|
||||
break;
|
||||
case 'v':
|
||||
ch = '\u000b';
|
||||
break;
|
||||
case 'u':
|
||||
case 'U':
|
||||
case 'x':
|
||||
ch = ScanUnicodeEscapeSequence(ch);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Invalid escape sequence '\\{ch}' at position {position}.");
|
||||
}
|
||||
|
||||
return ch;
|
||||
}
|
||||
|
||||
private char ScanUnicodeEscapeSequence(char ch)
|
||||
{
|
||||
var value = 0;
|
||||
|
||||
var count = ch == 'U' ? 8 : 4;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var digit = Peek();
|
||||
|
||||
int digitValue;
|
||||
|
||||
if (digit >= '0' && digit <= '9')
|
||||
{
|
||||
digitValue = digit - '0';
|
||||
}
|
||||
else if (digit >= 'a' && digit <= 'f')
|
||||
{
|
||||
digitValue = digit - 'a' + 10;
|
||||
}
|
||||
else if (digit >= 'A' && digit <= 'F')
|
||||
{
|
||||
digitValue = digit - 'A' + 10;
|
||||
}
|
||||
else if (ch != 'x')
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid unicode escape sequence at position {position}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
value = (value << 4) + digitValue;
|
||||
|
||||
Advance(1);
|
||||
}
|
||||
|
||||
return (char)value;
|
||||
}
|
||||
|
||||
private Token ScanCharacterLiteral()
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
while (true)
|
||||
{
|
||||
var ch = Peek();
|
||||
|
||||
switch (ch)
|
||||
{
|
||||
case '\0':
|
||||
throw new InvalidOperationException($"Unexpected end of character literal at position {position}.");
|
||||
case '\\':
|
||||
Advance(1);
|
||||
buffer.Append(ScanEscapeSequence());
|
||||
break;
|
||||
case '\'':
|
||||
Advance(1);
|
||||
|
||||
return new Token(TokenType.CharacterLiteral, buffer.ToString())
|
||||
{
|
||||
ValueKind = ValueKind.Character
|
||||
};
|
||||
default:
|
||||
if (buffer.Length > 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Too many characters in character literal at position {position}.");
|
||||
}
|
||||
|
||||
buffer.Append(ch);
|
||||
Advance(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Token ScanStringLiteral()
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
while (true)
|
||||
{
|
||||
var ch = Peek();
|
||||
|
||||
switch (ch)
|
||||
{
|
||||
case '\0':
|
||||
throw new InvalidOperationException($"Unexpected end of string literal at position {position}.");
|
||||
case '\\':
|
||||
Advance(1);
|
||||
buffer.Append(ScanEscapeSequence());
|
||||
break;
|
||||
case '"':
|
||||
Advance(1);
|
||||
return new Token(TokenType.StringLiteral, buffer.ToString())
|
||||
{
|
||||
ValueKind = ValueKind.String
|
||||
};
|
||||
default:
|
||||
buffer.Append(ch);
|
||||
Advance(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Token ScanNumericLiteral()
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
var hasDecimal = false;
|
||||
var hasFSuffix = false;
|
||||
var hasDSuffix = false;
|
||||
var hasMSuffix = false;
|
||||
var hasLSuffix = false;
|
||||
var hasExponent = false;
|
||||
var hasHex = false;
|
||||
var hasUSuffix = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var ch = Peek();
|
||||
|
||||
if (ch == '0')
|
||||
{
|
||||
var next = Peek(1);
|
||||
|
||||
if (next == 'x' || next == 'X')
|
||||
{
|
||||
hasHex = true;
|
||||
Advance(2);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch >= '0' && ch <= '9')
|
||||
{
|
||||
buffer.Append(ch);
|
||||
Advance(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '.')
|
||||
{
|
||||
if (hasDecimal)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected character '{ch}' at position {position}.");
|
||||
}
|
||||
|
||||
hasDecimal = true;
|
||||
|
||||
buffer.Append(ch);
|
||||
|
||||
Advance(1);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == 'l' || ch == 'L')
|
||||
{
|
||||
if (hasLSuffix)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected character '{ch}' at position {position}.");
|
||||
}
|
||||
|
||||
hasLSuffix = true;
|
||||
|
||||
Advance(1);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == 'u' || ch == 'U')
|
||||
{
|
||||
if (hasUSuffix)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected character '{ch}' at position {position}.");
|
||||
}
|
||||
|
||||
hasUSuffix = true;
|
||||
|
||||
Advance(1);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasHex && ((ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')))
|
||||
{
|
||||
buffer.Append(ch);
|
||||
Advance(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == 'e' || ch == 'E')
|
||||
{
|
||||
if (hasExponent)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected character '{ch}' at position {position}.");
|
||||
}
|
||||
|
||||
hasExponent = true;
|
||||
buffer.Append(ch);
|
||||
Advance(1);
|
||||
|
||||
// Check for optional + or - after e/E
|
||||
ch = Peek();
|
||||
if (ch == '+' || ch == '-')
|
||||
{
|
||||
buffer.Append(ch);
|
||||
Advance(1);
|
||||
}
|
||||
|
||||
// Must have at least one digit after e/E
|
||||
ch = Peek();
|
||||
|
||||
if (ch < '0' || ch > '9')
|
||||
{
|
||||
throw new InvalidOperationException($"Expected digit after exponent at position {position}.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasDecimal || hasExponent)
|
||||
{
|
||||
switch (ch)
|
||||
{
|
||||
case 'F':
|
||||
case 'f':
|
||||
hasFSuffix = true;
|
||||
Advance(1);
|
||||
break;
|
||||
case 'D':
|
||||
case 'd':
|
||||
hasDSuffix = true;
|
||||
Advance(1);
|
||||
break;
|
||||
case 'M':
|
||||
case 'm':
|
||||
hasMSuffix = true;
|
||||
Advance(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
var value = new Token(TokenType.NumericLiteral);
|
||||
|
||||
var valueKind = ValueKind.None;
|
||||
|
||||
if (hasDecimal || hasExponent)
|
||||
{
|
||||
valueKind = ValueKind.Double;
|
||||
}
|
||||
|
||||
if (hasFSuffix)
|
||||
{
|
||||
valueKind = ValueKind.Float;
|
||||
}
|
||||
|
||||
if (hasDSuffix)
|
||||
{
|
||||
valueKind = ValueKind.Double;
|
||||
}
|
||||
|
||||
if (hasMSuffix)
|
||||
{
|
||||
valueKind = ValueKind.Decimal;
|
||||
}
|
||||
|
||||
switch (valueKind)
|
||||
{
|
||||
case ValueKind.Float:
|
||||
value.ValueKind = ValueKind.Float;
|
||||
value.FloatValue = GetValueFloat(buffer.ToString());
|
||||
break;
|
||||
case ValueKind.Double:
|
||||
value.ValueKind = ValueKind.Double;
|
||||
value.DoubleValue = GetValueDouble(buffer.ToString());
|
||||
break;
|
||||
case ValueKind.Decimal:
|
||||
value.ValueKind = ValueKind.Decimal;
|
||||
value.DecimalValue = GetValueDecimal(buffer.ToString());
|
||||
break;
|
||||
default:
|
||||
var val = GetValueUInt64(buffer.ToString(), hasHex);
|
||||
|
||||
if (!hasUSuffix && !hasLSuffix)
|
||||
{
|
||||
if (val <= Int32.MaxValue)
|
||||
{
|
||||
value.ValueKind = ValueKind.Int;
|
||||
value.IntValue = (int)val;
|
||||
}
|
||||
else if (val <= UInt32.MaxValue)
|
||||
{
|
||||
value.ValueKind = ValueKind.UInt;
|
||||
value.UintValue = (uint)val;
|
||||
}
|
||||
else if (val <= Int64.MaxValue)
|
||||
{
|
||||
value.ValueKind = ValueKind.Long;
|
||||
value.LongValue = (long)val;
|
||||
}
|
||||
else
|
||||
{
|
||||
value.ValueKind = ValueKind.ULong;
|
||||
value.UlongValue = val;
|
||||
}
|
||||
}
|
||||
else if (hasUSuffix && !hasLSuffix)
|
||||
{
|
||||
if (val <= UInt32.MaxValue)
|
||||
{
|
||||
value.ValueKind = ValueKind.UInt;
|
||||
value.UintValue = (uint)val;
|
||||
}
|
||||
else
|
||||
{
|
||||
value.ValueKind = ValueKind.ULong;
|
||||
value.UlongValue = val;
|
||||
}
|
||||
}
|
||||
else if (!hasUSuffix & hasLSuffix)
|
||||
{
|
||||
if (val <= Int64.MaxValue)
|
||||
{
|
||||
value.ValueKind = ValueKind.Long;
|
||||
value.LongValue = (long)val;
|
||||
}
|
||||
else
|
||||
{
|
||||
value.ValueKind = ValueKind.ULong;
|
||||
value.UlongValue = val;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
value.ValueKind = ValueKind.ULong;
|
||||
value.UlongValue = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static decimal GetValueDecimal(string text)
|
||||
{
|
||||
if (!decimal.TryParse(text, NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid numeric literal: {text}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static float GetValueFloat(string text)
|
||||
{
|
||||
if (!float.TryParse(text, NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid numeric literal: {text}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static double GetValueDouble(string text)
|
||||
{
|
||||
if (!double.TryParse(text, NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid numeric literal: {text}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ulong GetValueUInt64(string text, bool isHex)
|
||||
{
|
||||
if (!UInt64.TryParse(text, isHex ? NumberStyles.AllowHexSpecifier : NumberStyles.None, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid numeric literal: {text}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Token ScanIdentifier()
|
||||
{
|
||||
var startOffset = position;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (position == expression.Length)
|
||||
{
|
||||
var length = position - startOffset;
|
||||
|
||||
return new Token(TokenType.Identifier, expression.Substring(startOffset, length));
|
||||
}
|
||||
|
||||
switch (Peek())
|
||||
{
|
||||
case '\0':
|
||||
case ' ':
|
||||
case '\r':
|
||||
case '\n':
|
||||
case '\t':
|
||||
case '!':
|
||||
case '%':
|
||||
case '(':
|
||||
case ')':
|
||||
case '*':
|
||||
case '+':
|
||||
case ',':
|
||||
case '-':
|
||||
case '.':
|
||||
case '/':
|
||||
case ':':
|
||||
case ';':
|
||||
case '<':
|
||||
case '=':
|
||||
case '>':
|
||||
case '?':
|
||||
case '[':
|
||||
case ']':
|
||||
case '^':
|
||||
case '{':
|
||||
case '|':
|
||||
case '}':
|
||||
case '~':
|
||||
case '"':
|
||||
case '\'':
|
||||
// All of the following characters are not valid in an
|
||||
// identifier. If we see any of them, then we know we're
|
||||
// done.
|
||||
return new Token(TokenType.Identifier, expression[startOffset..position]);
|
||||
case >= '0' and <= '9':
|
||||
if (position == startOffset)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
goto case '_';
|
||||
}
|
||||
case (>= 'a' and <= 'z') or (>= 'A' and <= 'Z'):
|
||||
case '_':
|
||||
// All of these characters are valid inside an identifier.
|
||||
// consume it and keep processing.
|
||||
Advance(1);
|
||||
continue;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unexpected character '{Peek()}' at position {position}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
919
Radzen.Blazor/ExpressionParser.cs
Normal file
919
Radzen.Blazor/ExpressionParser.cs
Normal file
@@ -0,0 +1,919 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
|
||||
namespace Radzen;
|
||||
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// Parse lambda expressions from strings.
|
||||
/// </summary>
|
||||
public class ExpressionParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a lambda expression that returns a boolean value.
|
||||
/// </summary>
|
||||
public static Expression<Func<T, bool>> ParsePredicate<T>(string expression, Func<string, Type?>? typeResolver = null)
|
||||
{
|
||||
return ParseLambda<T, bool>(expression, typeResolver);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a lambda expression that returns a typed result.
|
||||
/// </summary>
|
||||
public static Expression<Func<T, TResult>> ParseLambda<T, TResult>(string expression, Func<string, Type?>? typeResolver = null)
|
||||
{
|
||||
var lambda = ParseLambda(expression, typeof(T), typeResolver);
|
||||
|
||||
return Expression.Lambda<Func<T, TResult>>(lambda.Body, lambda.Parameters[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a lambda expression that returns untyped result.
|
||||
/// </summary>
|
||||
public static LambdaExpression ParseLambda<T>(string expression, Func<string, Type?>? typeLocator = null)
|
||||
{
|
||||
return ParseLambda(expression, typeof(T), typeLocator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a lambda expression that returns untyped result.
|
||||
/// </summary>
|
||||
public static LambdaExpression ParseLambda(string expression, Type type, Func<string, Type?>? typeResolver = null)
|
||||
{
|
||||
var parser = new ExpressionParser(expression, typeResolver);
|
||||
|
||||
return parser.ParseLambda(type);
|
||||
}
|
||||
|
||||
private readonly List<Token> tokens;
|
||||
private int position = 0;
|
||||
private readonly Func<string, Type?>? typeResolver;
|
||||
private readonly Stack<ParameterExpression> parameterStack = new();
|
||||
|
||||
private ExpressionParser(string expression, Func<string, Type?>? typeResolver = null)
|
||||
{
|
||||
this.typeResolver = typeResolver;
|
||||
tokens = ExpressionLexer.Scan(expression);
|
||||
}
|
||||
|
||||
Token Expect(TokenType type)
|
||||
{
|
||||
if (position >= tokens.Count)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected end of expression. Expected token: {type}");
|
||||
}
|
||||
|
||||
var token = tokens[position];
|
||||
|
||||
if (token.Type != type)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected token: {token.Type}. Expected: {type}");
|
||||
}
|
||||
|
||||
position++;
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
void Advance(int count)
|
||||
{
|
||||
position += count;
|
||||
}
|
||||
|
||||
Token Peek(int offset = 0)
|
||||
{
|
||||
if (position + offset >= tokens.Count)
|
||||
{
|
||||
return new Token(TokenType.None, string.Empty);
|
||||
}
|
||||
|
||||
return tokens[position + offset];
|
||||
}
|
||||
|
||||
private LambdaExpression ParseLambda(Type paramType)
|
||||
{
|
||||
var parameterIdentifier = Expect(TokenType.Identifier);
|
||||
|
||||
var parameter = Expression.Parameter(paramType, parameterIdentifier.Value);
|
||||
|
||||
parameterStack.Push(parameter);
|
||||
|
||||
Expect(TokenType.EqualsGreaterThan);
|
||||
|
||||
var body = ParseExpression(parameter);
|
||||
|
||||
parameterStack.Pop();
|
||||
|
||||
return Expression.Lambda(body, parameter);
|
||||
}
|
||||
|
||||
private Expression ParseExpression(ParameterExpression parameter)
|
||||
{
|
||||
var left = ParseBinary(parameter);
|
||||
var token = Peek();
|
||||
|
||||
if (token.Type is TokenType.AmpersandAmpersand)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var right = ParseExpression(parameter) ?? throw new InvalidOperationException($"Expected expression after {token.Value} at position {position}");
|
||||
|
||||
left = Expression.AndAlso(left, right);
|
||||
}
|
||||
else if (token.Type is TokenType.BarBar)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var right = ParseExpression(parameter) ?? throw new InvalidOperationException($"Expected expression after {token.Value} at position {position}");
|
||||
|
||||
left = Expression.OrElse(left, right);
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression ParseBinary(ParameterExpression parameter)
|
||||
{
|
||||
var left = ParseNullCoalescing(parameter);
|
||||
var token = Peek();
|
||||
|
||||
if (token.Type is TokenType.EqualsEquals or TokenType.NotEquals or TokenType.GreaterThan or TokenType.LessThan or TokenType.LessThanOrEqual or TokenType.GreaterThanOrEqual)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseBinary(parameter) ?? throw new InvalidOperationException($"Expected expression after {token.Value} at position {position}");
|
||||
left = Expression.MakeBinary(token.Type.ToExpressionType(), left, ConvertIfNeeded(right, left.Type));
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression ParseNullCoalescing(ParameterExpression parameter)
|
||||
{
|
||||
var left = ParseTernary(parameter);
|
||||
var token = Peek();
|
||||
|
||||
while (token.Type == TokenType.QuestionMarkQuestionMark)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var right = ParseTernary(parameter) ?? throw new InvalidOperationException($"Expected expression after ?? at position {position}");
|
||||
|
||||
if (right.Type == typeof(object))
|
||||
{
|
||||
right = ConvertIfNeeded(right, left.Type);
|
||||
}
|
||||
|
||||
left = Expression.Coalesce(left, right);
|
||||
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression ParseTernary(ParameterExpression parameter)
|
||||
{
|
||||
var condition = ParseOr(parameter);
|
||||
|
||||
if (Peek().Type == TokenType.QuestionMark)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var trueExpression = ParseOr(parameter);
|
||||
|
||||
Expect(TokenType.Colon);
|
||||
|
||||
var falseExpression = ParseOr(parameter);
|
||||
|
||||
if (trueExpression is ConstantExpression trueConst && trueConst.Value == null && falseExpression is not ConstantExpression)
|
||||
{
|
||||
trueExpression = Expression.Constant(null, falseExpression.Type);
|
||||
}
|
||||
else if (falseExpression is ConstantExpression falseConst && falseConst.Value == null && trueExpression is not ConstantExpression)
|
||||
{
|
||||
falseExpression = Expression.Constant(null, trueExpression.Type);
|
||||
}
|
||||
|
||||
var ternary = Expression.Condition(condition, trueExpression, falseExpression);
|
||||
|
||||
return ParseMemberAccess(ternary, parameter);
|
||||
}
|
||||
|
||||
return ParseMemberAccess(condition, parameter);
|
||||
}
|
||||
|
||||
private Expression ParseMemberAccess(Expression expression, ParameterExpression parameter)
|
||||
{
|
||||
var token = Peek();
|
||||
while (token.Type is TokenType.Dot or TokenType.QuestionDot or TokenType.OpenBracket)
|
||||
{
|
||||
if (token.Type == TokenType.Dot)
|
||||
{
|
||||
Advance(1);
|
||||
token = Expect(TokenType.Identifier);
|
||||
if (Peek().Type == TokenType.OpenParen)
|
||||
{
|
||||
expression = ParseInvocation(expression, token.Value, parameter);
|
||||
}
|
||||
else
|
||||
{
|
||||
expression = Expression.PropertyOrField(expression, token.Value);
|
||||
}
|
||||
}
|
||||
else if (token.Type == TokenType.QuestionDot)
|
||||
{
|
||||
Advance(1);
|
||||
token = Expect(TokenType.Identifier);
|
||||
|
||||
var check = Expression.Equal(expression, Expression.Constant(null));
|
||||
|
||||
if (Peek().Type == TokenType.OpenParen)
|
||||
{
|
||||
var call = ParseInvocation(expression, token.Value, parameter);
|
||||
expression = Expression.Condition(check, Expression.Constant(null, call.Type), call);
|
||||
}
|
||||
else
|
||||
{
|
||||
var access = Expression.PropertyOrField(expression, token.Value);
|
||||
|
||||
expression = Expression.Condition(check, Expression.Default(access.Type), access);
|
||||
|
||||
var nextToken = Peek();
|
||||
|
||||
if (nextToken.Type == TokenType.Dot || nextToken.Type == TokenType.QuestionDot)
|
||||
{
|
||||
var nextAccess = ParseMemberAccess(access, parameter);
|
||||
|
||||
expression = Expression.Condition(check, Expression.Default(nextAccess.Type), nextAccess);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (token.Type == TokenType.OpenBracket)
|
||||
{
|
||||
Advance(1);
|
||||
var index = ParseExpression(parameter);
|
||||
Expect(TokenType.CloseBracket);
|
||||
|
||||
if (expression.Type.IsArray)
|
||||
{
|
||||
expression = Expression.ArrayIndex(expression, index);
|
||||
}
|
||||
else
|
||||
{
|
||||
var indexer = expression.Type.GetProperty("Item") ?? throw new InvalidOperationException($"Type {expression.Type} does not have an indexer property");
|
||||
|
||||
expression = Expression.Property(expression, indexer, index);
|
||||
}
|
||||
}
|
||||
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return expression;
|
||||
}
|
||||
|
||||
private MethodCallExpression ParseInvocation(Expression expression, string methodName, ParameterExpression parameter)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var arguments = new List<Expression>();
|
||||
|
||||
if (Peek().Type != TokenType.CloseParen)
|
||||
{
|
||||
while (Peek().Type != TokenType.CloseParen)
|
||||
{
|
||||
var token = Peek();
|
||||
|
||||
if (token.Type == TokenType.Identifier && Peek(1).Type == TokenType.EqualsGreaterThan)
|
||||
{
|
||||
var lambdaParameterName = token.Value;
|
||||
|
||||
Advance(2);
|
||||
|
||||
Type? lambdaParameterType = null;
|
||||
|
||||
var extensionMethod = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(m => m.Name == methodName && m.GetParameters().Length == 2);
|
||||
|
||||
if (extensionMethod != null)
|
||||
{
|
||||
lambdaParameterType = GetItemType(expression.Type);
|
||||
}
|
||||
|
||||
if (lambdaParameterType == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Could not infer type for lambda parameter {lambdaParameterName}");
|
||||
}
|
||||
|
||||
var lambdaParameter = Expression.Parameter(lambdaParameterType, lambdaParameterName);
|
||||
parameterStack.Push(lambdaParameter);
|
||||
var lambdaBody = ParseExpression(lambdaParameter);
|
||||
parameterStack.Pop();
|
||||
arguments.Add(Expression.Lambda(lambdaBody, lambdaParameter));
|
||||
}
|
||||
else
|
||||
{
|
||||
arguments.Add(ParseExpression(parameter));
|
||||
}
|
||||
|
||||
if (Peek().Type == TokenType.Comma)
|
||||
{
|
||||
Advance(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Expect(TokenType.CloseParen);
|
||||
|
||||
var argumentTypes = arguments.Select(a => a.Type).ToArray();
|
||||
|
||||
var method = expression.Type.GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance, null, argumentTypes, null);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
return Expression.Call(expression, method, arguments);
|
||||
}
|
||||
|
||||
method = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(m => m.Name == methodName && m.GetParameters().Length == arguments.Count + 1);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
var argumentType = GetItemType(expression.Type);
|
||||
|
||||
if (argumentType == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot determine item type for {expression.Type}");
|
||||
}
|
||||
|
||||
if (method.IsGenericMethodDefinition)
|
||||
{
|
||||
method = method.MakeGenericMethod(argumentType);
|
||||
}
|
||||
|
||||
var parameters = method.GetParameters();
|
||||
|
||||
var argumentsWithInstance = new[] { expression }.Concat(arguments).ToArray();
|
||||
|
||||
return Expression.Call(method, argumentsWithInstance.Select((a, index) => ConvertIfNeeded(a, parameters[index].ParameterType)));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"No suitable method '{methodName}' found for type '{expression.Type}'");
|
||||
}
|
||||
|
||||
private static Type? GetItemType(Type enumerableOrArray)
|
||||
{
|
||||
return enumerableOrArray.IsArray ? enumerableOrArray.GetElementType() : enumerableOrArray.GetGenericArguments()[0];
|
||||
}
|
||||
|
||||
private Expression? ParseTerm(ParameterExpression parameter)
|
||||
{
|
||||
var token = Peek();
|
||||
|
||||
if (token.Type == TokenType.None)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token.Type == TokenType.OpenParen)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
if (TryParseCastExpression(parameter, out var expression))
|
||||
{
|
||||
return expression;
|
||||
}
|
||||
|
||||
expression = ParseExpression(parameter);
|
||||
|
||||
Expect(TokenType.CloseParen);
|
||||
|
||||
return expression;
|
||||
}
|
||||
|
||||
if (token.Type == TokenType.Identifier)
|
||||
{
|
||||
var matchingParameter = parameterStack.FirstOrDefault(p => p.Name == token.Value);
|
||||
if (matchingParameter != null)
|
||||
{
|
||||
Advance(1);
|
||||
return ParseMemberAccess(matchingParameter, parameter);
|
||||
}
|
||||
|
||||
var type = GetWellKnownType(token.Value);
|
||||
|
||||
if (type != null)
|
||||
{
|
||||
Advance(1);
|
||||
return ParseStaticMemberAccess(type, parameter);
|
||||
}
|
||||
|
||||
if (Peek(1).Type == TokenType.OpenParen)
|
||||
{
|
||||
Advance(1);
|
||||
return ParseInvocation(parameter, token.Value, parameter);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected identifier: {token.Value}");
|
||||
}
|
||||
|
||||
if (token.Type == TokenType.ExclamationMark)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var operand = ParseTerm(parameter) ?? throw new InvalidOperationException($"Expected expression after ! at position {position}");
|
||||
|
||||
operand = ConvertIfNeeded(operand, typeof(bool));
|
||||
|
||||
return Expression.Not(operand);
|
||||
}
|
||||
|
||||
if (token.Type == TokenType.Minus)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var operand = ParseTerm(parameter) ?? throw new InvalidOperationException($"Expected expression after - at position {position}");
|
||||
|
||||
return Expression.Negate(operand);
|
||||
}
|
||||
|
||||
if (token.Type == TokenType.Plus)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var operand = ParseTerm(parameter) ?? throw new InvalidOperationException($"Expected expression after + at position {position}");
|
||||
|
||||
return operand;
|
||||
}
|
||||
|
||||
switch (token.Type)
|
||||
{
|
||||
case TokenType.CharacterLiteral:
|
||||
case TokenType.StringLiteral:
|
||||
case TokenType.NullLiteral:
|
||||
case TokenType.NumericLiteral:
|
||||
case TokenType.TrueLiteral:
|
||||
case TokenType.FalseLiteral:
|
||||
Advance(1);
|
||||
return token.ToConstantExpression();
|
||||
case TokenType.New:
|
||||
Advance(1);
|
||||
|
||||
token = Peek();
|
||||
|
||||
if (token.Type == TokenType.OpenBrace)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var properties = new List<(string Name, Expression Expression)>();
|
||||
|
||||
if (Peek().Type != TokenType.CloseBrace)
|
||||
{
|
||||
do
|
||||
{
|
||||
token = Peek();
|
||||
string propertyName;
|
||||
Expression propertyExpression;
|
||||
|
||||
if (token.Type == TokenType.Identifier)
|
||||
{
|
||||
propertyName = token.Value;
|
||||
Advance(1);
|
||||
if (Peek().Type == TokenType.Dot || Peek().Type == TokenType.QuestionDot)
|
||||
{
|
||||
// Handle nested property access
|
||||
Expression expr = propertyName == parameter.Name ? (Expression)parameter : Expression.Property(parameter, propertyName);
|
||||
propertyExpression = ParseMemberAccess(expr, parameter);
|
||||
|
||||
// Get the last identifier token's value
|
||||
var lastToken = tokens[position - 1];
|
||||
if (lastToken.Type == TokenType.Identifier)
|
||||
{
|
||||
propertyName = lastToken.Value;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Expect(TokenType.Equals);
|
||||
propertyExpression = ParseExpression(parameter);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
propertyExpression = ParseExpression(parameter);
|
||||
|
||||
if (propertyExpression is MemberExpression memberExpression)
|
||||
{
|
||||
propertyName = memberExpression.Member.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid anonymous type member expression at position {position}");
|
||||
}
|
||||
}
|
||||
|
||||
properties.Add((propertyName, propertyExpression));
|
||||
|
||||
if (Peek().Type == TokenType.Comma)
|
||||
{
|
||||
Advance(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
} while (Peek().Type != TokenType.CloseBrace);
|
||||
}
|
||||
|
||||
Expect(TokenType.CloseBrace);
|
||||
|
||||
var propertyTypes = properties.Select(p => p.Expression.Type).ToArray();
|
||||
var propertyNames = properties.Select(p => p.Name).ToArray();
|
||||
var dynamicType = DynamicTypeFactory.CreateType(parameter.Type.Name, propertyNames, propertyTypes);
|
||||
var bindings = properties.Select(p => Expression.Bind(dynamicType.GetProperty(p.Name)!, p.Expression));
|
||||
return Expression.MemberInit(Expression.New(dynamicType), bindings);
|
||||
}
|
||||
else
|
||||
{
|
||||
Type? elementType = null;
|
||||
var nullable = false;
|
||||
|
||||
if (token.Type == TokenType.Identifier)
|
||||
{
|
||||
var typeName = token.Value;
|
||||
elementType = GetWellKnownType(typeName);
|
||||
Advance(1);
|
||||
|
||||
if (Peek().Type == TokenType.QuestionMark)
|
||||
{
|
||||
nullable = true;
|
||||
Advance(1);
|
||||
}
|
||||
}
|
||||
|
||||
Expect(TokenType.OpenBracket);
|
||||
Expect(TokenType.CloseBracket);
|
||||
Expect(TokenType.OpenBrace);
|
||||
|
||||
var elements = new List<Expression>();
|
||||
if (Peek().Type != TokenType.CloseBrace)
|
||||
{
|
||||
do
|
||||
{
|
||||
elements.Add(ParseExpression(parameter));
|
||||
if (Peek().Type == TokenType.Comma)
|
||||
{
|
||||
Advance(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
} while (Peek().Type != TokenType.CloseBrace);
|
||||
}
|
||||
|
||||
Expect(TokenType.CloseBrace);
|
||||
|
||||
if (elementType == null)
|
||||
{
|
||||
elementType = elements.Count > 0 ? elements[0].Type : typeof(object);
|
||||
}
|
||||
|
||||
if (nullable)
|
||||
{
|
||||
elementType = typeof(Nullable<>).MakeGenericType(elementType);
|
||||
}
|
||||
|
||||
return Expression.NewArrayInit(elementType, elements.Select(e => ConvertIfNeeded(e, elementType)));
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Unexpected token: {token.Type} at position {position}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryParseCastExpression(ParameterExpression parameter, out Expression expression)
|
||||
{
|
||||
expression = null!;
|
||||
|
||||
var token = Peek();
|
||||
|
||||
if (token.Type != TokenType.Identifier)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var typeName = new StringBuilder(token.Value);
|
||||
var index = position + 1;
|
||||
var typeCast = true;
|
||||
var nullable = false;
|
||||
|
||||
while (index < tokens.Count)
|
||||
{
|
||||
token = tokens[index];
|
||||
|
||||
if (token.Type == TokenType.Dot)
|
||||
{
|
||||
index++;
|
||||
if (index >= tokens.Count || tokens[index].Type != TokenType.Identifier)
|
||||
{
|
||||
typeCast = false;
|
||||
break;
|
||||
}
|
||||
typeName.Append('.').Append(tokens[index].Value);
|
||||
index++;
|
||||
}
|
||||
else if (token.Type == TokenType.QuestionMark)
|
||||
{
|
||||
nullable = true;
|
||||
index++;
|
||||
if (index >= tokens.Count || tokens[index].Type != TokenType.CloseParen)
|
||||
{
|
||||
typeCast = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (token.Type == TokenType.CloseParen)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
typeCast = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeCast && index < tokens.Count && tokens[index].Type == TokenType.CloseParen)
|
||||
{
|
||||
var name = typeName.ToString();
|
||||
|
||||
var type = GetWellKnownType(name) ?? typeResolver?.Invoke(name) ?? throw new InvalidOperationException($"Could not resolve type: {typeName}");
|
||||
|
||||
if (nullable && type.IsValueType)
|
||||
{
|
||||
type = typeof(Nullable<>).MakeGenericType(type);
|
||||
}
|
||||
|
||||
position = index;
|
||||
|
||||
Advance(1);
|
||||
|
||||
if (Peek().Type == TokenType.OpenParen && TryParseCastExpression(parameter, out var innerExpression))
|
||||
{
|
||||
expression = Expression.Convert(innerExpression, type);
|
||||
}
|
||||
else
|
||||
{
|
||||
var source = ParseTerm(parameter) ?? throw new InvalidOperationException($"Expected expression to cast at position {position}");
|
||||
expression = Expression.Convert(source, type);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private Expression ParseStaticMemberAccess(Type type, ParameterExpression parameter)
|
||||
{
|
||||
Expect(TokenType.Dot);
|
||||
|
||||
var token = Expect(TokenType.Identifier);
|
||||
|
||||
if (Peek().Type == TokenType.OpenParen)
|
||||
{
|
||||
return ParseStaticInvocation(type, token.Value, parameter);
|
||||
}
|
||||
else
|
||||
{
|
||||
var member = (MemberInfo?)type.GetProperty(token.Value) ?? type.GetField(token.Value);
|
||||
|
||||
if (member == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Member {token.Value} not found on type {type.Name}");
|
||||
}
|
||||
|
||||
return Expression.MakeMemberAccess(null, member);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Expected method invocation after {token.Value} at position {position}");
|
||||
}
|
||||
|
||||
private Expression ParseStaticInvocation(Type type, string methodName, ParameterExpression parameter)
|
||||
{
|
||||
Advance(1);
|
||||
|
||||
var arguments = new List<Expression>();
|
||||
|
||||
if (Peek().Type != TokenType.CloseParen)
|
||||
{
|
||||
arguments.Add(ParseExpression(parameter));
|
||||
|
||||
while (Peek().Type == TokenType.Comma)
|
||||
{
|
||||
Advance(1);
|
||||
arguments.Add(ParseExpression(parameter));
|
||||
}
|
||||
}
|
||||
|
||||
Expect(TokenType.CloseParen);
|
||||
|
||||
var method = type.GetMethod(methodName, [.. arguments.Select(a => a.Type)]) ?? throw new InvalidOperationException($"Method {methodName} not found on type {type.Name}");
|
||||
|
||||
return Expression.Call(null, method, arguments);
|
||||
}
|
||||
|
||||
private static Type? GetWellKnownType(string typeName)
|
||||
{
|
||||
return typeName switch
|
||||
{
|
||||
nameof(DateTime) => typeof(DateTime),
|
||||
nameof(DateOnly) => typeof(DateOnly),
|
||||
nameof(TimeOnly) => typeof(TimeOnly),
|
||||
nameof(DateTimeOffset) => typeof(DateTimeOffset),
|
||||
nameof(Guid) => typeof(Guid),
|
||||
nameof(CultureInfo) => typeof(CultureInfo),
|
||||
nameof(DateTimeStyles) => typeof(DateTimeStyles),
|
||||
nameof(DateTimeKind) => typeof(DateTimeKind),
|
||||
nameof(Double) or "double" => typeof(double),
|
||||
nameof(Single) or "float" => typeof(float),
|
||||
nameof(Int32) or "int" => typeof(int),
|
||||
nameof(Int64) or "long" => typeof(long),
|
||||
nameof(Int16) or "short" => typeof(short),
|
||||
nameof(Byte) or "byte" => typeof(byte),
|
||||
nameof(SByte) or "sbyte" => typeof(sbyte),
|
||||
nameof(UInt32) or "uint" => typeof(uint),
|
||||
nameof(UInt64) or "ulong" => typeof(ulong),
|
||||
nameof(UInt16) or "ushort" => typeof(ushort),
|
||||
nameof(Boolean) or "bool" => typeof(bool),
|
||||
nameof(Char) or "char" => typeof(char),
|
||||
nameof(Decimal) or "decimal" => typeof(decimal),
|
||||
nameof(String) or "string" => typeof(string),
|
||||
nameof(Math) => typeof(Math),
|
||||
nameof(Convert) => typeof(Convert),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private Expression ParseOr(ParameterExpression parameter)
|
||||
{
|
||||
var left = ParseMemberAccess(ParseAnd(parameter), parameter);
|
||||
|
||||
var token = Peek();
|
||||
while (token.Type == TokenType.BarBar)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseMemberAccess(ParseAnd(parameter) ?? throw new InvalidOperationException($"Expected expression after || at position {position}"), parameter);
|
||||
left = Expression.OrElse(left, right);
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression ParseAnd(ParameterExpression parameter)
|
||||
{
|
||||
var left = ParseMemberAccess(ParseComparison(parameter), parameter);
|
||||
|
||||
var token = Peek();
|
||||
while (token.Type == TokenType.AmpersandAmpersand)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseMemberAccess(ParseComparison(parameter) ?? throw new InvalidOperationException($"Expected expression after && at position {position}"), parameter);
|
||||
left = Expression.AndAlso(left, right);
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression ParseComparison(ParameterExpression parameter)
|
||||
{
|
||||
var left = ParseShift(parameter);
|
||||
|
||||
var token = Peek();
|
||||
if (token.Type is TokenType.EqualsEquals or TokenType.NotEquals or TokenType.GreaterThan or TokenType.LessThan or TokenType.LessThanOrEqual or TokenType.GreaterThanOrEqual)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseShift(parameter) ?? throw new InvalidOperationException($"Expected expression after {token.Value} at position {position}");
|
||||
left = Expression.MakeBinary(token.Type.ToExpressionType(), left, ConvertIfNeeded(right, left.Type));
|
||||
}
|
||||
|
||||
return ParseBinaryAnd(left, parameter);
|
||||
}
|
||||
|
||||
private Expression ParseBinaryAnd(Expression left, ParameterExpression parameter)
|
||||
{
|
||||
var token = Peek();
|
||||
while (token.Type == TokenType.Ampersand)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseShift(parameter) ?? throw new InvalidOperationException($"Expected expression after & at position {position}");
|
||||
left = Expression.MakeBinary(ExpressionType.And, left, ConvertIfNeeded(right, left.Type));
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return ParseBinaryXor(left, parameter);
|
||||
}
|
||||
|
||||
private Expression ParseBinaryXor(Expression left, ParameterExpression parameter)
|
||||
{
|
||||
var token = Peek();
|
||||
while (token.Type == TokenType.Caret)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseBinaryAnd(ParseShift(parameter), parameter) ?? throw new InvalidOperationException($"Expected expression after ^ at position {position}");
|
||||
left = Expression.MakeBinary(ExpressionType.ExclusiveOr, left, ConvertIfNeeded(right, left.Type));
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return ParseBinaryOr(left, parameter);
|
||||
}
|
||||
|
||||
private Expression ParseBinaryOr(Expression left, ParameterExpression parameter)
|
||||
{
|
||||
var token = Peek();
|
||||
while (token.Type == TokenType.Bar)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseBinaryXor(ParseShift(parameter), parameter) ?? throw new InvalidOperationException($"Expected expression after | at position {position}");
|
||||
left = Expression.MakeBinary(ExpressionType.Or, left, ConvertIfNeeded(right, left.Type));
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression ParseShift(ParameterExpression parameter)
|
||||
{
|
||||
var left = ParseAdditive(parameter);
|
||||
|
||||
var token = Peek();
|
||||
while (token.Type is TokenType.LessThanLessThan or TokenType.GreaterThanGreaterThan)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseAdditive(parameter) ?? throw new InvalidOperationException($"Expected expression after {token.Value} at position {position}");
|
||||
left = Expression.MakeBinary(token.Type.ToExpressionType(), left, ConvertIfNeeded(right, left.Type));
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression ParseAdditive(ParameterExpression parameter)
|
||||
{
|
||||
var left = ParseMultiplicative(parameter);
|
||||
|
||||
var token = Peek();
|
||||
while (token.Type is TokenType.Plus or TokenType.Minus)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseMultiplicative(parameter) ?? throw new InvalidOperationException($"Expected expression after {token.Value} at position {position}");
|
||||
|
||||
if (token.Type == TokenType.Plus && left.Type == typeof(string))
|
||||
{
|
||||
left = Expression.Call(null, typeof(string).GetMethod(nameof(string.Concat), [typeof(string), typeof(string)])!, left, ConvertIfNeeded(right, typeof(string)));
|
||||
}
|
||||
else
|
||||
{
|
||||
left = Expression.MakeBinary(token.Type.ToExpressionType(), left, ConvertIfNeeded(right, left.Type));
|
||||
}
|
||||
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression ParseMultiplicative(ParameterExpression parameter)
|
||||
{
|
||||
var left = ParseTerm(parameter) ?? throw new InvalidOperationException($"Expected expression at position {position}");
|
||||
|
||||
var token = Peek();
|
||||
while (token.Type is TokenType.Star or TokenType.Slash)
|
||||
{
|
||||
Advance(1);
|
||||
var right = ParseTerm(parameter) ?? throw new InvalidOperationException($"Expected expression after {token.Value} at position {position}");
|
||||
left = Expression.MakeBinary(token.Type.ToExpressionType(), left, ConvertIfNeeded(right, left.Type));
|
||||
token = Peek();
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private static Expression ConvertIfNeeded(Expression expression, Type targetType)
|
||||
{
|
||||
if (expression is not LambdaExpression)
|
||||
{
|
||||
return expression.Type == targetType ? expression : Expression.Convert(expression, targetType);
|
||||
}
|
||||
|
||||
return expression;
|
||||
}
|
||||
}
|
||||
473
Radzen.Blazor/ExpressionSerializer.cs
Normal file
473
Radzen.Blazor/ExpressionSerializer.cs
Normal file
@@ -0,0 +1,473 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes LINQ Expression Trees into C# string representations.
|
||||
/// </summary>
|
||||
public class ExpressionSerializer : ExpressionVisitor
|
||||
{
|
||||
private readonly StringBuilder _sb = new StringBuilder();
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a given LINQ Expression into a C# string.
|
||||
/// </summary>
|
||||
/// <param name="expression">The expression to serialize.</param>
|
||||
/// <returns>A string representation of the expression.</returns>
|
||||
public string Serialize(Expression expression)
|
||||
{
|
||||
_sb.Clear();
|
||||
Visit(expression);
|
||||
return _sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Expression VisitLambda<T>(Expression<T> node)
|
||||
{
|
||||
if (node.Parameters.Count > 1)
|
||||
{
|
||||
_sb.Append("(");
|
||||
for (int i = 0; i < node.Parameters.Count; i++)
|
||||
{
|
||||
if (i > 0) _sb.Append(", ");
|
||||
_sb.Append(node.Parameters[i].Name);
|
||||
}
|
||||
_sb.Append(") => ");
|
||||
}
|
||||
else
|
||||
{
|
||||
_sb.Append(node.Parameters[0].Name);
|
||||
_sb.Append(" => ");
|
||||
}
|
||||
Visit(node.Body);
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Expression VisitParameter(ParameterExpression node)
|
||||
{
|
||||
_sb.Append(node.Name);
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Expression VisitMember(MemberExpression node)
|
||||
{
|
||||
if (node.Expression != null)
|
||||
{
|
||||
Visit(node.Expression);
|
||||
_sb.Append($".{node.Member.Name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_sb.Append(node.Member.Name);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Expression VisitMethodCall(MethodCallExpression node)
|
||||
{
|
||||
if (node.Method.IsStatic && node.Arguments.Count > 0 &&
|
||||
(node.Method.DeclaringType == typeof(Enumerable) ||
|
||||
node.Method.DeclaringType == typeof(Queryable)))
|
||||
{
|
||||
Visit(node.Arguments[0]);
|
||||
_sb.Append($".{node.Method.Name}(");
|
||||
|
||||
for (int i = 1; i < node.Arguments.Count; i++)
|
||||
{
|
||||
if (i > 1) _sb.Append(", ");
|
||||
|
||||
if (node.Arguments[i] is NewArrayExpression arrayExpr)
|
||||
{
|
||||
VisitNewArray(arrayExpr);
|
||||
}
|
||||
else
|
||||
{
|
||||
Visit(node.Arguments[i]);
|
||||
}
|
||||
}
|
||||
|
||||
_sb.Append(")");
|
||||
}
|
||||
else if (node.Method.IsStatic)
|
||||
{
|
||||
_sb.Append($"{node.Method.DeclaringType.Name}.{node.Method.Name}(");
|
||||
|
||||
for (int i = 0; i < node.Arguments.Count; i++)
|
||||
{
|
||||
if (i > 0) _sb.Append(", ");
|
||||
Visit(node.Arguments[i]);
|
||||
}
|
||||
|
||||
_sb.Append(")");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (node.Object != null)
|
||||
{
|
||||
Visit(node.Object);
|
||||
_sb.Append($".{node.Method.Name}(");
|
||||
}
|
||||
else
|
||||
{
|
||||
_sb.Append($"{node.Method.Name}(");
|
||||
}
|
||||
|
||||
for (int i = 0; i < node.Arguments.Count; i++)
|
||||
{
|
||||
if (i > 0) _sb.Append(", ");
|
||||
Visit(node.Arguments[i]);
|
||||
}
|
||||
|
||||
_sb.Append(")");
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Expression VisitUnary(UnaryExpression node)
|
||||
{
|
||||
if (node.NodeType == ExpressionType.Not)
|
||||
{
|
||||
_sb.Append("(!(");
|
||||
Visit(node.Operand);
|
||||
_sb.Append("))");
|
||||
}
|
||||
else if (node.NodeType == ExpressionType.Convert)
|
||||
{
|
||||
if (node.Operand is IndexExpression indexExpr)
|
||||
{
|
||||
_sb.Append($"({node.Type.DisplayName(true).Replace("+",".")})");
|
||||
|
||||
Visit(indexExpr.Object);
|
||||
|
||||
_sb.Append("[");
|
||||
Visit(indexExpr.Arguments[0]);
|
||||
_sb.Append("]");
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
Visit(node.Operand);
|
||||
}
|
||||
else
|
||||
{
|
||||
_sb.Append(node.NodeType switch
|
||||
{
|
||||
ExpressionType.Negate => "-",
|
||||
ExpressionType.UnaryPlus => "+",
|
||||
_ => throw new NotSupportedException($"Unsupported unary operator: {node.NodeType}")
|
||||
});
|
||||
Visit(node.Operand);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Expression VisitConstant(ConstantExpression node)
|
||||
{
|
||||
_sb.Append(FormatValue(node.Value));
|
||||
return node;
|
||||
}
|
||||
|
||||
internal static string FormatValue(object value)
|
||||
{
|
||||
if (value == null)
|
||||
return "null";
|
||||
|
||||
return value switch
|
||||
{
|
||||
string s when s == string.Empty => @"""""",
|
||||
null => "null",
|
||||
string s => @$"""{s.Replace("\"", "\\\"")}""",
|
||||
char c => $"'{c}'",
|
||||
bool b => b.ToString().ToLowerInvariant(),
|
||||
DateTime dt => FormatDateTime(dt),
|
||||
DateTimeOffset dto => $"DateTime.Parse(\"{dto.UtcDateTime:yyyy-MM-ddTHH:mm:ss.fffZ}\")",
|
||||
DateOnly dateOnly => $"DateOnly.Parse(\"{dateOnly.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}\", CultureInfo.InvariantCulture)",
|
||||
TimeOnly timeOnly => $"TimeOnly.Parse(\"{timeOnly.ToString("HH:mm:ss", CultureInfo.InvariantCulture)}\", CultureInfo.InvariantCulture)",
|
||||
Guid guid => $"Guid.Parse(\"{guid.ToString("D", CultureInfo.InvariantCulture)}\")",
|
||||
IEnumerable enumerable when value is not string => FormatEnumerable(enumerable),
|
||||
_ => value.GetType().IsEnum
|
||||
? $"({value.GetType().FullName.Replace("+", ".")})" + Convert.ChangeType(value, Enum.GetUnderlyingType(value.GetType()), CultureInfo.InvariantCulture).ToString()
|
||||
: Convert.ToString(value, CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatDateTime(DateTime dateTime)
|
||||
{
|
||||
var finalDate = dateTime.TimeOfDay == TimeSpan.Zero ? dateTime.Date : dateTime;
|
||||
var dateFormat = dateTime.TimeOfDay == TimeSpan.Zero ? "yyyy-MM-dd" : "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
|
||||
return $"DateTime.SpecifyKind(DateTime.Parse(\"{finalDate.ToString(dateFormat, CultureInfo.InvariantCulture)}\", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeKind.{Enum.GetName(finalDate.Kind)})";
|
||||
}
|
||||
|
||||
private static string FormatEnumerable(IEnumerable enumerable)
|
||||
{
|
||||
var arrayType = enumerable.AsQueryable().ElementType;
|
||||
|
||||
var items = enumerable.Cast<object>().Select(FormatValue);
|
||||
return $"new {(Nullable.GetUnderlyingType(arrayType) != null ? arrayType.DisplayName(true).Replace("+", ".") : "")}[] {{ {string.Join(", ", items)} }}";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Expression VisitNewArray(NewArrayExpression node)
|
||||
{
|
||||
bool needsParentheses = node.NodeType == ExpressionType.NewArrayInit &&
|
||||
(node.Expressions.Count > 1 || node.Expressions[0].NodeType != ExpressionType.Constant);
|
||||
|
||||
if (needsParentheses) _sb.Append("(");
|
||||
|
||||
_sb.Append("new [] { ");
|
||||
bool first = true;
|
||||
foreach (var expr in node.Expressions)
|
||||
{
|
||||
if (!first) _sb.Append(", ");
|
||||
first = false;
|
||||
Visit(expr);
|
||||
}
|
||||
_sb.Append(" }");
|
||||
|
||||
if (needsParentheses) _sb.Append(")");
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Expression VisitBinary(BinaryExpression node)
|
||||
{
|
||||
_sb.Append("(");
|
||||
Visit(node.Left);
|
||||
_sb.Append($" {GetOperator(node.NodeType)} ");
|
||||
Visit(node.Right);
|
||||
_sb.Append(")");
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Expression VisitConditional(ConditionalExpression node)
|
||||
{
|
||||
_sb.Append("(");
|
||||
Visit(node.Test);
|
||||
_sb.Append(" ? ");
|
||||
Visit(node.IfTrue);
|
||||
_sb.Append(" : ");
|
||||
Visit(node.IfFalse);
|
||||
_sb.Append(")");
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an ExpressionType to its corresponding C# operator.
|
||||
/// </summary>
|
||||
/// <param name="type">The ExpressionType to map.</param>
|
||||
/// <returns>A string representation of the corresponding C# operator.</returns>
|
||||
private static string GetOperator(ExpressionType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
ExpressionType.Add => "+",
|
||||
ExpressionType.Subtract => "-",
|
||||
ExpressionType.Multiply => "*",
|
||||
ExpressionType.Divide => "/",
|
||||
ExpressionType.AndAlso => "&&",
|
||||
ExpressionType.OrElse => "||",
|
||||
ExpressionType.Equal => "==",
|
||||
ExpressionType.NotEqual => "!=",
|
||||
ExpressionType.LessThan => "<",
|
||||
ExpressionType.LessThanOrEqual => "<=",
|
||||
ExpressionType.GreaterThan => ">",
|
||||
ExpressionType.GreaterThanOrEqual => ">=",
|
||||
ExpressionType.Coalesce => "??",
|
||||
_ => throw new NotSupportedException($"Unsupported operator: {type}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides an extension method for displaying type names.
|
||||
/// </summary>
|
||||
public static class SharedTypeExtensions
|
||||
{
|
||||
private static readonly Dictionary<Type, string> BuiltInTypeNames = new()
|
||||
{
|
||||
{ typeof(bool), "bool" },
|
||||
{ typeof(byte), "byte" },
|
||||
{ typeof(char), "char" },
|
||||
{ typeof(decimal), "decimal" },
|
||||
{ typeof(double), "double" },
|
||||
{ typeof(float), "float" },
|
||||
{ typeof(int), "int" },
|
||||
{ typeof(long), "long" },
|
||||
{ typeof(object), "object" },
|
||||
{ typeof(sbyte), "sbyte" },
|
||||
{ typeof(short), "short" },
|
||||
{ typeof(string), "string" },
|
||||
{ typeof(uint), "uint" },
|
||||
{ typeof(ulong), "ulong" },
|
||||
{ typeof(ushort), "ushort" },
|
||||
{ typeof(void), "void" }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Unwraps nullable type.
|
||||
/// </summary>
|
||||
public static Type UnwrapNullableType(this Type type)
|
||||
=> Nullable.GetUnderlyingType(type) ?? type;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a display name for the given type.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to display.</param>
|
||||
/// <param name="fullName">Indicates whether to use the full name.</param>
|
||||
/// <param name="compilable">Indicates whether to use a compilable format.</param>
|
||||
/// <returns>A string representing the type name.</returns>
|
||||
public static string DisplayName(this Type type, bool fullName = true, bool compilable = false)
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
ProcessType(stringBuilder, type, fullName, compilable);
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
private static void ProcessType(StringBuilder builder, Type type, bool fullName, bool compilable)
|
||||
{
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var genericArguments = type.GetGenericArguments();
|
||||
ProcessGenericType(builder, type, genericArguments, genericArguments.Length, fullName, compilable);
|
||||
}
|
||||
else if (type.IsArray)
|
||||
{
|
||||
ProcessArrayType(builder, type, fullName, compilable);
|
||||
}
|
||||
else if (BuiltInTypeNames.TryGetValue(type, out var builtInName))
|
||||
{
|
||||
builder.Append(builtInName);
|
||||
}
|
||||
else if (!type.IsGenericParameter)
|
||||
{
|
||||
if (compilable)
|
||||
{
|
||||
if (type.IsNested)
|
||||
{
|
||||
ProcessType(builder, type.DeclaringType!, fullName, compilable);
|
||||
builder.Append('.');
|
||||
}
|
||||
else if (fullName)
|
||||
{
|
||||
builder.Append(type.Namespace).Append('.');
|
||||
}
|
||||
|
||||
builder.Append(type.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(fullName ? type.FullName : type.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessArrayType(StringBuilder builder, Type type, bool fullName, bool compilable)
|
||||
{
|
||||
var innerType = type;
|
||||
while (innerType.IsArray)
|
||||
{
|
||||
innerType = innerType.GetElementType()!;
|
||||
}
|
||||
|
||||
ProcessType(builder, innerType, fullName, compilable);
|
||||
|
||||
while (type.IsArray)
|
||||
{
|
||||
builder.Append('[');
|
||||
builder.Append(',', type.GetArrayRank() - 1);
|
||||
builder.Append(']');
|
||||
type = type.GetElementType()!;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessGenericType(
|
||||
StringBuilder builder,
|
||||
Type type,
|
||||
Type[] genericArguments,
|
||||
int length,
|
||||
bool fullName,
|
||||
bool compilable)
|
||||
{
|
||||
if (type.IsConstructedGenericType
|
||||
&& type.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||
{
|
||||
ProcessType(builder, type.UnwrapNullableType(), fullName, compilable);
|
||||
builder.Append('?');
|
||||
return;
|
||||
}
|
||||
|
||||
var offset = type.IsNested ? type.DeclaringType!.GetGenericArguments().Length : 0;
|
||||
|
||||
if (compilable)
|
||||
{
|
||||
if (type.IsNested)
|
||||
{
|
||||
ProcessType(builder, type.DeclaringType!, fullName, compilable);
|
||||
builder.Append('.');
|
||||
}
|
||||
else if (fullName)
|
||||
{
|
||||
builder.Append(type.Namespace);
|
||||
builder.Append('.');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (fullName)
|
||||
{
|
||||
if (type.IsNested)
|
||||
{
|
||||
ProcessGenericType(builder, type.DeclaringType!, genericArguments, offset, fullName, compilable);
|
||||
builder.Append('+');
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(type.Namespace);
|
||||
builder.Append('.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var genericPartIndex = type.Name.IndexOf('`');
|
||||
if (genericPartIndex <= 0)
|
||||
{
|
||||
builder.Append(type.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
builder.Append(type.Name, 0, genericPartIndex);
|
||||
builder.Append('<');
|
||||
|
||||
for (var i = offset; i < length; i++)
|
||||
{
|
||||
ProcessType(builder, genericArguments[i], fullName, compilable);
|
||||
if (i + 1 == length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(',');
|
||||
if (!genericArguments[i + 1].IsGenericParameter)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('>');
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,9 @@ namespace Radzen
|
||||
/// Gets the field identifier.
|
||||
/// </summary>
|
||||
/// <value>The field identifier.</value>
|
||||
public FieldIdentifier FieldIdentifier { get; private set; }
|
||||
[Parameter]
|
||||
public FieldIdentifier FieldIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value expression.
|
||||
/// </summary>
|
||||
|
||||
@@ -187,7 +187,7 @@ namespace Radzen.Blazor
|
||||
|
||||
if (Visible)
|
||||
{
|
||||
JSRuntime.InvokeVoidAsync("Radzen.destroyGauge", Element);
|
||||
JSRuntime.InvokeVoid("Radzen.destroyGauge", Element);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,12 @@ namespace Radzen.Blazor
|
||||
/// <param name="appointments">The appointments for this range.</param>
|
||||
Task SelectMonth(DateTime monthStart, IEnumerable<AppointmentData> appointments);
|
||||
/// <summary>
|
||||
/// Selects the specified day.
|
||||
/// </summary>
|
||||
/// <param name="day">The selected day.</param>
|
||||
/// <param name="appointments">The appointments for this range.</param>
|
||||
Task SelectDay(DateTime day, IEnumerable<AppointmentData> appointments);
|
||||
/// <summary>
|
||||
/// Selects the specified more link.
|
||||
/// </summary>
|
||||
/// <param name="start">The start.</param>
|
||||
@@ -94,8 +100,9 @@ namespace Radzen.Blazor
|
||||
/// </summary>
|
||||
/// <param name="start">The start of the slot.</param>
|
||||
/// <param name="end">The end of the slot.</param>
|
||||
/// <param name="getAppointments">Function to return appointments for this range.</param>
|
||||
/// <returns>A dictionary containing the HTML attributes for the specified slot.</returns>
|
||||
IDictionary<string, object> GetSlotAttributes(DateTime start, DateTime end);
|
||||
IDictionary<string, object> GetSlotAttributes(DateTime start, DateTime end, Func<IEnumerable<AppointmentData>> getAppointments);
|
||||
/// <summary>
|
||||
/// Renders the appointment.
|
||||
/// </summary>
|
||||
|
||||
24
Radzen.Blazor/JSRuntimeExtensions.cs
Normal file
24
Radzen.Blazor/JSRuntimeExtensions.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Radzen;
|
||||
|
||||
static class JSRuntimeExtensions
|
||||
{
|
||||
public static void InvokeVoid(this IJSRuntime jsRuntime, string identifier, params object[] args)
|
||||
{
|
||||
_ = jsRuntime.InvokeVoidAsync(identifier, args).FireAndForget();
|
||||
}
|
||||
|
||||
private static async ValueTask FireAndForget(this ValueTask task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,10 +62,19 @@ namespace Radzen.Blazor
|
||||
var start = Input.Start;
|
||||
var end = Input.End;
|
||||
|
||||
|
||||
if (start == end)
|
||||
{
|
||||
start = 0;
|
||||
end += NiceNumber(end / ticks, false);
|
||||
if (end < 0)
|
||||
{
|
||||
start += NiceNumber(end / ticks, false);
|
||||
end = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
start = 0;
|
||||
end += NiceNumber(end / ticks, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (Round && end < 0)
|
||||
@@ -113,12 +122,12 @@ namespace Radzen.Blazor
|
||||
Round = false;
|
||||
}
|
||||
|
||||
if (step <= 0)
|
||||
if (step == 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException("Step must be greater than zero");
|
||||
throw new ArgumentOutOfRangeException("Step must be non-zero");
|
||||
}
|
||||
|
||||
return (start, end, step);
|
||||
return (start, end, Math.Abs(step));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,4 @@
|
||||
<assembly fullname="System.Core">
|
||||
<type fullname="System.Linq.Queryable" preserve="all" />
|
||||
</assembly>
|
||||
<assembly fullname="System.Linq.Dynamic.Core" />
|
||||
</linker>
|
||||
47
Radzen.Blazor/Markdown/AtxHeading.cs
Normal file
47
Radzen.Blazor/Markdown/AtxHeading.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a markdown ATX heading: <c># Heading</c>.
|
||||
/// </summary>
|
||||
public class AtxHeading : Heading
|
||||
{
|
||||
private static readonly Regex MarkerRegex = new(@"^#{1,6}(?:[ \t]+|$)");
|
||||
|
||||
private static readonly Regex StartRegex = new(@"^[ \t]*#+[ \t]*$");
|
||||
|
||||
private static readonly Regex EndRegex = new(@"[ \t]+#+[ \t]*$");
|
||||
|
||||
internal static BlockStart Start(BlockParser parser, Block block)
|
||||
{
|
||||
if (parser.Indented)
|
||||
{
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
|
||||
var line = parser.CurrentLine[parser.NextNonSpace..];
|
||||
|
||||
var match = MarkerRegex.Match(line);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
parser.AdvanceNextNonSpace();
|
||||
parser.AdvanceOffset(match.Length, false);
|
||||
parser.CloseUnmatchedBlocks();
|
||||
var container = parser.AddChild<AtxHeading>(parser.NextNonSpace);
|
||||
container.Level = match.Value.Trim().Length;
|
||||
|
||||
// remove trailing ###s:
|
||||
line = parser.CurrentLine[parser.Offset..];
|
||||
|
||||
container.Value = EndRegex.Replace(StartRegex.Replace(line, ""), "");
|
||||
|
||||
parser.AdvanceOffset(parser.CurrentLine.Length - parser.Offset, false);
|
||||
|
||||
return BlockStart.Leaf;
|
||||
}
|
||||
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
}
|
||||
380
Radzen.Blazor/Markdown/BlazorMarkdownRenderer.cs
Normal file
380
Radzen.Blazor/Markdown/BlazorMarkdownRenderer.cs
Normal file
@@ -0,0 +1,380 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
#nullable enable
|
||||
|
||||
class BlazorMarkdownRendererOptions
|
||||
{
|
||||
public int AutoLinkHeadingDepth { get; set; }
|
||||
public bool AllowHtml { get; set; }
|
||||
|
||||
public IEnumerable<string>? AllowedHtmlTags { get; set; }
|
||||
|
||||
public IEnumerable<string>? AllowedHtmlAttributes { get; set; }
|
||||
}
|
||||
|
||||
class BlazorMarkdownRenderer(BlazorMarkdownRendererOptions options, RenderTreeBuilder builder, Action<RenderTreeBuilder, int> outlet) : NodeVisitorBase
|
||||
{
|
||||
public const string Outlet = "<!--rz-outlet-{0}-->";
|
||||
private static readonly Regex OutletRegex = new (@"<!--rz-outlet-(\d+)-->");
|
||||
private static readonly Regex HtmlTagRegex = new(@"<(\w+)((?:\s+[^>]*)?)\/?>");
|
||||
private static readonly Regex HtmlClosingTagRegex = new(@"</(\w+)>");
|
||||
private static readonly Regex AttributeRegex = new(@"(\w+)(?:\s*=\s*(?:([""'])(.*?)\2|([^\s>]+)))?");
|
||||
private readonly HtmlSanitizer sanitizer = new (options.AllowedHtmlTags, options.AllowedHtmlAttributes);
|
||||
|
||||
public override void VisitHeading(Heading heading)
|
||||
{
|
||||
builder.OpenComponent<RadzenText>(0);
|
||||
builder.AddAttribute(1, nameof(RadzenText.ChildContent), RenderChildren(heading.Children));
|
||||
|
||||
switch (heading.Level)
|
||||
{
|
||||
case 1:
|
||||
builder.AddAttribute(2, nameof(RadzenText.TextStyle), TextStyle.H1);
|
||||
break;
|
||||
case 2:
|
||||
builder.AddAttribute(3, nameof(RadzenText.TextStyle), TextStyle.H2);
|
||||
break;
|
||||
case 3:
|
||||
builder.AddAttribute(4, nameof(RadzenText.TextStyle), TextStyle.H3);
|
||||
break;
|
||||
case 4:
|
||||
builder.AddAttribute(5, nameof(RadzenText.TextStyle), TextStyle.H4);
|
||||
break;
|
||||
case 5:
|
||||
builder.AddAttribute(6, nameof(RadzenText.TextStyle), TextStyle.H5);
|
||||
break;
|
||||
case 6:
|
||||
builder.AddAttribute(7, nameof(RadzenText.TextStyle), TextStyle.H6);
|
||||
break;
|
||||
}
|
||||
|
||||
if (heading.Level <= options.AutoLinkHeadingDepth)
|
||||
{
|
||||
var anchor = Regex.Replace(heading.Value, @"[^\w\s-]", string.Empty).Replace(' ', '-').ToLowerInvariant().Trim();
|
||||
builder.AddAttribute(8, nameof(RadzenText.Anchor), anchor);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AddAttribute(9, nameof(RadzenText.Anchor), (string?)null);
|
||||
}
|
||||
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
public override void VisitTable(Table table)
|
||||
{
|
||||
builder.OpenComponent<RadzenTable>(0);
|
||||
builder.AddAttribute(1, nameof(RadzenTable.ChildContent), RenderChildren(table.Rows));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
public override void VisitTableRow(TableRow row)
|
||||
{
|
||||
builder.OpenComponent<RadzenTableRow>(0);
|
||||
builder.AddAttribute(1, nameof(RadzenTableRow.ChildContent), RenderChildren(row.Cells));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
public override void VisitTableCell(TableCell cell)
|
||||
{
|
||||
builder.OpenComponent<RadzenTableCell>(0);
|
||||
builder.AddAttribute(1, nameof(RadzenTableCell.ChildContent), RenderChildren(cell.Children));
|
||||
RenderCellAlignment(builder, cell.Alignment);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
private static void RenderCellAlignment(RenderTreeBuilder builder, TableCellAlignment alignment)
|
||||
{
|
||||
switch (alignment)
|
||||
{
|
||||
case TableCellAlignment.Center:
|
||||
builder.AddAttribute(2, nameof(RadzenTableCell.Style), "text-align: center");
|
||||
break;
|
||||
case TableCellAlignment.Right:
|
||||
builder.AddAttribute(3, nameof(RadzenTableCell.Style), "text-align: right");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public override void VisitTableHeaderRow(TableHeaderRow header)
|
||||
{
|
||||
builder.OpenComponent<RadzenTableHeader>(0);
|
||||
builder.AddAttribute(1, nameof(RadzenTableHeader.ChildContent), new RenderFragment(headerBuilder =>
|
||||
{
|
||||
headerBuilder.OpenComponent<RadzenTableHeaderRow>(0);
|
||||
headerBuilder.AddAttribute(1, nameof(RadzenTableHeaderRow.ChildContent), new RenderFragment(headerRowBuilder =>
|
||||
{
|
||||
foreach (var cell in header.Cells)
|
||||
{
|
||||
headerRowBuilder.OpenComponent<RadzenTableHeaderCell>(0);
|
||||
headerRowBuilder.AddAttribute(1, nameof(RadzenTableHeaderCell.ChildContent), RenderChildren(cell.Children));
|
||||
RenderCellAlignment(headerRowBuilder, cell.Alignment);
|
||||
headerRowBuilder.CloseComponent();
|
||||
}
|
||||
}));
|
||||
headerBuilder.CloseComponent();
|
||||
}));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
public override void VisitIndentedCodeBlock(IndentedCodeBlock code)
|
||||
{
|
||||
builder.OpenElement(0, "pre");
|
||||
builder.OpenElement(1, "code");
|
||||
builder.AddContent(2, code.Value);
|
||||
builder.CloseElement();
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitParagraph(Paragraph paragraph)
|
||||
{
|
||||
if (paragraph.Parent is ListItem item && item.Parent is List list && list.Tight)
|
||||
{
|
||||
VisitChildren(paragraph.Children);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.OpenComponent<RadzenText>(0);
|
||||
builder.AddAttribute(1, nameof(RadzenText.ChildContent), RenderChildren(paragraph.Children));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment RenderChildren(IEnumerable<INode> children)
|
||||
{
|
||||
return innerBuilder =>
|
||||
{
|
||||
var inner = new BlazorMarkdownRenderer(options, innerBuilder, outlet);
|
||||
inner.VisitChildren(children);
|
||||
};
|
||||
}
|
||||
|
||||
public override void VisitBlockQuote(BlockQuote blockQuote)
|
||||
{
|
||||
builder.OpenElement(0, "blockquote");
|
||||
VisitChildren(blockQuote.Children);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitCode(Code code)
|
||||
{
|
||||
builder.OpenElement(0, "code");
|
||||
builder.AddContent(1, code.Value);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitStrong(Strong strong)
|
||||
{
|
||||
builder.OpenElement(0, "strong");
|
||||
VisitChildren(strong.Children);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitEmphasis(Emphasis emphasis)
|
||||
{
|
||||
builder.OpenElement(0, "em");
|
||||
VisitChildren(emphasis.Children);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitLink(Link link)
|
||||
{
|
||||
builder.OpenComponent<RadzenLink>(0);
|
||||
|
||||
if (!HtmlSanitizer.IsDangerousUrl(link.Destination))
|
||||
{
|
||||
builder.AddAttribute(1, nameof(RadzenLink.Path), link.Destination);
|
||||
}
|
||||
|
||||
builder.AddAttribute(2, nameof(RadzenLink.ChildContent), RenderChildren(link.Children));
|
||||
|
||||
if (!string.IsNullOrEmpty(link.Title))
|
||||
{
|
||||
builder.AddAttribute(3, "title", link.Title);
|
||||
}
|
||||
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
public override void VisitImage(Image image)
|
||||
{
|
||||
builder.OpenComponent<RadzenImage>(0);
|
||||
|
||||
if (!HtmlSanitizer.IsDangerousUrl(image.Destination))
|
||||
{
|
||||
builder.AddAttribute(1, nameof(RadzenImage.Path), image.Destination);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(image.Title))
|
||||
{
|
||||
builder.AddAttribute(2, nameof(RadzenImage.AlternateText), image.Title);
|
||||
}
|
||||
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitOrderedList(OrderedList orderedList)
|
||||
{
|
||||
builder.OpenElement(0, "ol");
|
||||
VisitChildren(orderedList.Children);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitUnorderedList(UnorderedList unorderedList)
|
||||
{
|
||||
builder.OpenElement(0, "ul");
|
||||
VisitChildren(unorderedList.Children);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitListItem(ListItem listItem)
|
||||
{
|
||||
builder.OpenElement(0, "li");
|
||||
VisitChildren(listItem.Children);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitFencedCodeBlock(FencedCodeBlock fencedCodeBlock)
|
||||
{
|
||||
builder.OpenElement(0, "pre");
|
||||
builder.OpenElement(1, "code");
|
||||
builder.AddContent(2, fencedCodeBlock.Value);
|
||||
builder.CloseElement();
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitThematicBreak(ThematicBreak thematicBreak)
|
||||
{
|
||||
builder.OpenElement(0, "hr");
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitHtmlBlock(HtmlBlock htmlBlock)
|
||||
{
|
||||
var match = OutletRegex.Match(htmlBlock.Value);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
var markerId = Convert.ToInt32(match.Groups[1].Value);
|
||||
outlet(builder, markerId);
|
||||
}
|
||||
else if (options.AllowHtml)
|
||||
{
|
||||
var html = sanitizer.Sanitize(htmlBlock.Value);
|
||||
builder.AddMarkupContent(0, html);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AddContent(0, htmlBlock.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public override void VisitLineBreak(LineBreak lineBreak)
|
||||
{
|
||||
builder.OpenElement(0, "br");
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
public override void VisitText(Text text)
|
||||
{
|
||||
builder.AddContent(0, text.Value);
|
||||
}
|
||||
|
||||
private static bool IsVoidElement(string tagName)
|
||||
{
|
||||
return tagName.ToLowerInvariant() switch
|
||||
{
|
||||
"area" => true,
|
||||
"base" => true,
|
||||
"br" => true,
|
||||
"col" => true,
|
||||
"embed" => true,
|
||||
"hr" => true,
|
||||
"img" => true,
|
||||
"input" => true,
|
||||
"link" => true,
|
||||
"meta" => true,
|
||||
"param" => true,
|
||||
"source" => true,
|
||||
"track" => true,
|
||||
"wbr" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public override void VisitSoftLineBreak(SoftLineBreak softBreak)
|
||||
{
|
||||
builder.AddContent(0, "\n");
|
||||
}
|
||||
|
||||
public override void VisitHtmlInline(HtmlInline htmlInline)
|
||||
{
|
||||
var match = OutletRegex.Match(htmlInline.Value);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
var markerId = Convert.ToInt32(match.Groups[1].Value);
|
||||
outlet(builder, markerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.AllowHtml)
|
||||
{
|
||||
builder.AddContent(0, htmlInline.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
var html = sanitizer.Sanitize(htmlInline.Value);
|
||||
|
||||
var closingMatch = HtmlClosingTagRegex.Match(html);
|
||||
|
||||
if (closingMatch.Success)
|
||||
{
|
||||
builder.CloseElement();
|
||||
return;
|
||||
}
|
||||
|
||||
var openingMatch = HtmlTagRegex.Match(html);
|
||||
|
||||
if (openingMatch.Success)
|
||||
{
|
||||
var tagName = openingMatch.Groups[1].Value;
|
||||
|
||||
builder.OpenElement(0, tagName);
|
||||
|
||||
var attributes = openingMatch.Groups[2].Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(attributes))
|
||||
{
|
||||
var matches = AttributeRegex.Matches(attributes);
|
||||
|
||||
foreach (Match attribute in matches)
|
||||
{
|
||||
var name = attribute.Groups[1].Value;
|
||||
var value = name;
|
||||
|
||||
if (attribute.Groups[2].Success) // Quoted value (either single or double)
|
||||
{
|
||||
value = attribute.Groups[3].Value;
|
||||
}
|
||||
else if (attribute.Groups[4].Success) // Unquoted value
|
||||
{
|
||||
value = attribute.Groups[4].Value;
|
||||
}
|
||||
|
||||
builder.AddAttribute(1, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (html.EndsWith("/>") || IsVoidElement(tagName))
|
||||
{
|
||||
builder.CloseElement();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
73
Radzen.Blazor/Markdown/Block.cs
Normal file
73
Radzen.Blazor/Markdown/Block.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
#nullable enable
|
||||
/// <summary>
|
||||
/// Base class for a markdown block nodes.
|
||||
/// </summary>
|
||||
public abstract class Block : INode
|
||||
{
|
||||
/// <summary>
|
||||
/// Accepts a visitor.
|
||||
/// </summary>
|
||||
/// <param name="visitor"></param>
|
||||
public abstract void Accept(INodeVisitor visitor);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last child of the block.
|
||||
/// </summary>
|
||||
public virtual Block? LastChild => null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first child of the block.
|
||||
/// </summary>
|
||||
public virtual Block? FirstChild => null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the next sibling of the block.
|
||||
/// </summary>
|
||||
public virtual Block? Next => Parent.NextSibling(this);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the parent node of the block.
|
||||
/// </summary>
|
||||
public BlockContainer Parent { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Removes the block from its parent.
|
||||
/// </summary>
|
||||
public void Remove()
|
||||
{
|
||||
Parent.Remove(this);
|
||||
}
|
||||
|
||||
internal virtual BlockMatch Matches(BlockParser parser) => 0;
|
||||
|
||||
internal bool Open { get; set; } = true;
|
||||
|
||||
internal Range Range;
|
||||
|
||||
internal virtual void Close(BlockParser parser)
|
||||
{
|
||||
Open = false;
|
||||
}
|
||||
}
|
||||
|
||||
enum BlockMatch
|
||||
{
|
||||
Match,
|
||||
Skip,
|
||||
Break
|
||||
}
|
||||
|
||||
struct Position
|
||||
{
|
||||
public int Line { get; set; }
|
||||
public int Column { get; set; }
|
||||
}
|
||||
|
||||
struct Range
|
||||
{
|
||||
public Position Start;
|
||||
public Position End;
|
||||
}
|
||||
87
Radzen.Blazor/Markdown/BlockContainer.cs
Normal file
87
Radzen.Blazor/Markdown/BlockContainer.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
#nullable enable
|
||||
/// <summary>
|
||||
/// Base class for markdown block nodes that can contain other blocks.
|
||||
/// </summary>
|
||||
public abstract class BlockContainer : Block
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the children of the block.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Block> Children => children;
|
||||
|
||||
private readonly List<Block> children = [];
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the block can contain the specified node.
|
||||
/// </summary>
|
||||
public virtual bool CanContain(Block node) => false;
|
||||
|
||||
/// <summary>
|
||||
/// Appends a block to the children of the block.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the block.</typeparam>
|
||||
/// <param name="block">The block to add.</param>
|
||||
/// <returns>The added block.</returns>
|
||||
public virtual T Add<T>(T block) where T : Block
|
||||
{
|
||||
children.Add(block);
|
||||
|
||||
block.Parent = this;
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a block with another block.
|
||||
/// </summary>
|
||||
/// <param name="source">The block to replace.</param>
|
||||
/// <param name="target">The block to replace with.</param>
|
||||
public void Replace(Block source, Block target)
|
||||
{
|
||||
var index = children.IndexOf(source);
|
||||
|
||||
if (index >= 0)
|
||||
{
|
||||
children[index] = target;
|
||||
target.Parent = this;
|
||||
target.Range = source.Range;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a block from the children of the block.
|
||||
/// </summary>
|
||||
/// <param name="block">The block to remove.</param>
|
||||
public void Remove(Block block)
|
||||
{
|
||||
children.Remove(block);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the next sibling of the block.
|
||||
/// </summary>
|
||||
/// <param name="block">The block to get the next sibling of.</param>
|
||||
/// <returns>The next sibling of the block.</returns>
|
||||
public Block? NextSibling(Block block)
|
||||
{
|
||||
var index = children.IndexOf(block);
|
||||
|
||||
if (index >= 0 && index < children.Count - 1)
|
||||
{
|
||||
return children[index + 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Block? LastChild => children.Count > 0 ? children[^1] : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Block? FirstChild => children.Count > 0 ? children[0] : null;
|
||||
}
|
||||
491
Radzen.Blazor/Markdown/BlockParser.cs
Normal file
491
Radzen.Blazor/Markdown/BlockParser.cs
Normal file
@@ -0,0 +1,491 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
#nullable enable
|
||||
|
||||
class BlockParser
|
||||
{
|
||||
private static readonly string tagName = @"[A-Za-z][A-Za-z0-9-]*";
|
||||
private static readonly string attributeName = @"[a-zA-Z_:][a-zA-Z0-9:._-]*";
|
||||
private static readonly string unquotedValue = @"[^""'=<>`\x00-\x20]+";
|
||||
private static readonly string singleQuotedValue = @"'[^']*'";
|
||||
private static readonly string doubleQuotedValue = @"""[^""]*""";
|
||||
private static readonly string attributeValue = @$"(?:{unquotedValue}|{singleQuotedValue}|{doubleQuotedValue})";
|
||||
private static readonly string attributeValueSpec = @$"(?:\s*=\s*{attributeValue})";
|
||||
private static readonly string attribute = @$"(?:\s+{attributeName}{attributeValueSpec}?)";
|
||||
|
||||
private static readonly string OpenTag = @$"<{tagName}{attribute}*\s*/?>";
|
||||
private static readonly string CloseTag = @$"</{tagName}\s*[>]";
|
||||
private static readonly string htmlComment = @"<!-->|<!--->|<!--[\s\S]*?-->";
|
||||
private static readonly string processingInstruction = @"<\?[ \s\S]*?\?>";
|
||||
private static readonly string declaration = @$"<![A-Za-z]+[^>]*>";
|
||||
private static readonly string cdata = @"<!\[CDATA\[[\s\S]*?\]\]>";
|
||||
|
||||
public static readonly Regex HtmlRegex = new(@$"^(?:{OpenTag}|{CloseTag}|{htmlComment}|{processingInstruction}|{declaration}|{cdata})");
|
||||
|
||||
private BlockParser()
|
||||
{
|
||||
Tip = document;
|
||||
|
||||
OldTip = document;
|
||||
|
||||
lastMatchedContainer = document;
|
||||
}
|
||||
|
||||
public static Document Parse(string markdown)
|
||||
{
|
||||
var parser = new BlockParser();
|
||||
|
||||
var document = parser.ParseBlocks(markdown);
|
||||
|
||||
parser.ParseInlines(document);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static readonly Regex NewLineRegex = new(@"\r\n|\r|\n");
|
||||
|
||||
private readonly Document document = new();
|
||||
|
||||
private void ParseInlines(Document document)
|
||||
{
|
||||
var visitor = new InlineVisitor(linkReferences);
|
||||
document.Accept(visitor);
|
||||
}
|
||||
|
||||
public char Peek()
|
||||
{
|
||||
return CurrentLine.Peek(Offset);
|
||||
}
|
||||
|
||||
public char PeekNonSpace(int offset = 0)
|
||||
{
|
||||
return CurrentLine.Peek(NextNonSpace + offset);
|
||||
}
|
||||
|
||||
public void AdvanceOffset(int count, bool columns)
|
||||
{
|
||||
var currentLine = CurrentLine;
|
||||
char c;
|
||||
|
||||
while (count > 0 && Offset < currentLine.Length != default)
|
||||
{
|
||||
c = currentLine[Offset];
|
||||
|
||||
if (c == '\t')
|
||||
{
|
||||
var charsToTab = 4 - (Column % 4);
|
||||
|
||||
if (columns)
|
||||
{
|
||||
PartiallyConsumedTab = charsToTab > count;
|
||||
var charsToAdvance = charsToTab > count ? count : charsToTab;
|
||||
Column += charsToAdvance;
|
||||
Offset += PartiallyConsumedTab ? 0 : 1;
|
||||
count -= charsToAdvance;
|
||||
}
|
||||
else
|
||||
{
|
||||
PartiallyConsumedTab = false;
|
||||
Column += charsToTab;
|
||||
Offset += 1;
|
||||
count -= 1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
PartiallyConsumedTab = false;
|
||||
Offset += 1;
|
||||
Column += 1;
|
||||
count -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Document ParseBlocks(string markdown)
|
||||
{
|
||||
LineNumber = 0;
|
||||
|
||||
lastMatchedContainer = document;
|
||||
|
||||
var lines = NewLineRegex.Split(markdown);
|
||||
|
||||
var length = lines.Length;
|
||||
|
||||
if (markdown.EndsWith(InlineParser.LineFeed))
|
||||
{
|
||||
length--;
|
||||
}
|
||||
|
||||
for (var index = 0; index < length; index++)
|
||||
{
|
||||
IncorporateLine(lines[index]);
|
||||
}
|
||||
|
||||
while (Tip != null)
|
||||
{
|
||||
Close(Tip, length);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private void IncorporateLine(string line)
|
||||
{
|
||||
Offset = 0;
|
||||
Column = 0;
|
||||
Blank = false;
|
||||
PartiallyConsumedTab = false;
|
||||
LineNumber++;
|
||||
|
||||
Block container = document;
|
||||
OldTip = Tip;
|
||||
Block? tail;
|
||||
var allMatched = true;
|
||||
CurrentLine = line;
|
||||
|
||||
while ((tail = container.LastChild) != null && tail.Open)
|
||||
{
|
||||
container = tail;
|
||||
|
||||
FindNextNonSpace();
|
||||
|
||||
switch (container.Matches(this))
|
||||
{
|
||||
case BlockMatch.Match: // we've matched, keep going
|
||||
break;
|
||||
case BlockMatch.Skip: // we've failed to match a block
|
||||
allMatched = false;
|
||||
break;
|
||||
case BlockMatch.Break: // we've hit end of line for fenced code close and can return
|
||||
return;
|
||||
default:
|
||||
throw new InvalidOperationException("Invalid continue result");
|
||||
}
|
||||
|
||||
if (!allMatched)
|
||||
{
|
||||
container = container.Parent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
AllClosed = container == OldTip;
|
||||
lastMatchedContainer = container;
|
||||
|
||||
var matchedLeaf = container is not (Paragraph or Table) && container is Leaf;
|
||||
|
||||
while (!matchedLeaf)
|
||||
{
|
||||
FindNextNonSpace();
|
||||
|
||||
int blockIndex;
|
||||
|
||||
for (blockIndex = 0; blockIndex < blockStarts.Length; blockIndex++)
|
||||
{
|
||||
var blockStart = blockStarts[blockIndex];
|
||||
|
||||
var result = blockStart(this, container);
|
||||
|
||||
if (result == BlockStart.Container)
|
||||
{
|
||||
container = Tip;
|
||||
break;
|
||||
}
|
||||
else if (result == BlockStart.Leaf)
|
||||
{
|
||||
container = Tip;
|
||||
matchedLeaf = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (blockIndex == blockStarts.Length)
|
||||
{
|
||||
AdvanceNextNonSpace();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// What remains at the offset is a text line. Add the text to the
|
||||
// appropriate container.
|
||||
|
||||
if (!AllClosed && !Blank && this.Tip is Paragraph or Table)
|
||||
{
|
||||
// lazy paragraph continuation
|
||||
if (Tip is Paragraph paragraph)
|
||||
{
|
||||
paragraph.AddLine(this);
|
||||
}
|
||||
else if (Tip is Table table)
|
||||
{
|
||||
table.AddLine(this);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// not a lazy continuation
|
||||
|
||||
// finalize any blocks not matched
|
||||
CloseUnmatchedBlocks();
|
||||
|
||||
if (container is Leaf leaf)
|
||||
{
|
||||
leaf.AddLine(this);
|
||||
|
||||
if (container is HtmlBlock block && block.Type >= 1 && block.Type <= 5 && HtmlBlockCloseRegex[block.Type].IsMatch(line[Offset..]))
|
||||
{
|
||||
LastLineLength = line.Length;
|
||||
Close(container, LineNumber);
|
||||
}
|
||||
}
|
||||
else if (Offset < line.Length && !Blank)
|
||||
{
|
||||
var paragraph = AddChild<Paragraph>(Offset);
|
||||
AdvanceNextNonSpace();
|
||||
paragraph.AddLine(this);
|
||||
}
|
||||
}
|
||||
LastLineLength = line.Length;
|
||||
}
|
||||
|
||||
public int LastLineLength { get; set; }
|
||||
|
||||
public void Close(Block block, int lineNumber)
|
||||
{
|
||||
var above = block.Parent;
|
||||
block.Range.End.Line = lineNumber;
|
||||
block.Range.End.Column = LastLineLength;
|
||||
block.Close(this);
|
||||
Tip = above;
|
||||
}
|
||||
|
||||
public T AddChild<T>(int offset) where T : Block, new()
|
||||
{
|
||||
var node = new T();
|
||||
|
||||
AddChild(node, offset);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
public void AddChild(Block node, int offset)
|
||||
{
|
||||
while (Tip is not BlockContainer container || !container.CanContain(node))
|
||||
{
|
||||
Close(Tip, LineNumber - 1);
|
||||
}
|
||||
|
||||
if (Tip is BlockContainer parent)
|
||||
{
|
||||
parent.Add(node);
|
||||
}
|
||||
|
||||
var columnNumber = offset + 1; // offset 0 = column 1
|
||||
|
||||
node.Range.Start.Line = LineNumber;
|
||||
node.Range.Start.Column = columnNumber;
|
||||
|
||||
Tip = node;
|
||||
}
|
||||
|
||||
public void CloseUnmatchedBlocks()
|
||||
{
|
||||
if (!AllClosed)
|
||||
{
|
||||
while (OldTip != lastMatchedContainer)
|
||||
{
|
||||
var parent = OldTip.Parent;
|
||||
Close(OldTip, LineNumber - 1);
|
||||
OldTip = parent;
|
||||
}
|
||||
|
||||
AllClosed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void AdvanceNextNonSpace()
|
||||
{
|
||||
Offset = NextNonSpace;
|
||||
Column = NextNonSpaceColumn;
|
||||
PartiallyConsumedTab = false;
|
||||
}
|
||||
|
||||
private static readonly Func<BlockParser, Block, BlockStart>[] blockStarts =
|
||||
[
|
||||
BlockQuote.Start,
|
||||
AtxHeading.Start,
|
||||
FencedCodeBlock.Start,
|
||||
HtmlBlock.Start,
|
||||
SetExtHeading.Start,
|
||||
ThematicBreak.Start,
|
||||
ListItem.Start,
|
||||
IndentedCodeBlock.Start,
|
||||
Table.Start,
|
||||
];
|
||||
|
||||
public bool AllClosed { get; private set; }
|
||||
|
||||
private Block lastMatchedContainer;
|
||||
|
||||
public void FindNextNonSpace()
|
||||
{
|
||||
var currentLine = CurrentLine;
|
||||
var i = Offset;
|
||||
var cols = Column;
|
||||
char c = default;
|
||||
|
||||
while (i < currentLine.Length)
|
||||
{
|
||||
c = currentLine[i];
|
||||
|
||||
if (c == ' ')
|
||||
{
|
||||
i++;
|
||||
cols++;
|
||||
}
|
||||
else if (c == '\t')
|
||||
{
|
||||
i++;
|
||||
cols += 4 - (cols % 4);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Blank = c == '\n' || c == '\r' || i == currentLine.Length;
|
||||
NextNonSpace = i;
|
||||
NextNonSpaceColumn = cols;
|
||||
Indent = NextNonSpaceColumn - Column;
|
||||
Indented = Indent >= CodeIndent;
|
||||
}
|
||||
|
||||
public const int CodeIndent = 4;
|
||||
|
||||
public int Indent { get; private set; }
|
||||
public bool Indented { get; private set; }
|
||||
public int NextNonSpaceColumn { get; private set; }
|
||||
|
||||
public int NextNonSpace { get; private set; }
|
||||
|
||||
public bool Blank { get; private set; }
|
||||
public bool PartiallyConsumedTab { get; private set; }
|
||||
public Block Tip { get; set; }
|
||||
public Block OldTip { get; private set; }
|
||||
public string CurrentLine { get; private set; } = string.Empty;
|
||||
public int Offset { get; set; }
|
||||
public int Column { get; set; }
|
||||
public int LineNumber { get; private set; }
|
||||
|
||||
private static readonly Regex LinkReferenceRegex = new(@"^[ \t]{0,3}\[");
|
||||
|
||||
private readonly Dictionary<string, LinkReference> linkReferences = [];
|
||||
|
||||
// https://spec.commonmark.org/0.31.2/#html-blocks
|
||||
internal static readonly Regex[] HtmlBlockOpenRegex = [
|
||||
new (@"."), // dummy for 1 based indexing
|
||||
new (@"^<(?:script|pre|textarea|style)(?:\s|>|$)", RegexOptions.IgnoreCase),
|
||||
new (@"^<!--"),
|
||||
new (@"^<[?]"),
|
||||
new (@"^<![A-Za-z]"),
|
||||
new (@"^<!\[CDATA\["),
|
||||
new (@"^<[/]?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[123456]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|search|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|[/]?[>]|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
new (@$"^(?:{OpenTag}|{CloseTag})\s*$", RegexOptions.IgnoreCase)
|
||||
];
|
||||
|
||||
private static readonly Regex[] HtmlBlockCloseRegex = [
|
||||
new (@"."), // dummy for 1 based indexing
|
||||
new (@"</(?:script|pre|textarea|style)>", RegexOptions.IgnoreCase),
|
||||
new (@"-->"),
|
||||
new (@"\?>"),
|
||||
new (@">"),
|
||||
new (@"\]\]>")
|
||||
];
|
||||
|
||||
|
||||
public bool TryParseLinkReference(string markdown, out int newIndex)
|
||||
{
|
||||
newIndex = 0;
|
||||
|
||||
if (!LinkReferenceRegex.IsMatch(markdown))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var position = 0;
|
||||
|
||||
while (position < markdown.Length - 1 && (markdown[position] is not InlineParser.CloseBracket || (position > 0 && markdown[position - 1] is InlineParser.Backslash)))
|
||||
{
|
||||
position++;
|
||||
}
|
||||
|
||||
if (position >= markdown.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
position++;
|
||||
|
||||
if (position >= markdown.Length || markdown[position] is not InlineParser.Colon)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var colonIndex = position;
|
||||
var closeIndex = colonIndex - 1;
|
||||
var openIndex = 0;
|
||||
|
||||
while (openIndex < closeIndex && markdown[openIndex] is not InlineParser.OpenBracket)
|
||||
{
|
||||
openIndex++;
|
||||
}
|
||||
|
||||
if (openIndex == closeIndex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var id = new StringBuilder();
|
||||
|
||||
for (var index = openIndex + 1; index < closeIndex; index++)
|
||||
{
|
||||
var next = index < closeIndex - 1 ? markdown[index + 1] : default;
|
||||
|
||||
if (markdown[index] is not InlineParser.Backslash || !next.IsPunctuation())
|
||||
{
|
||||
id.Append(markdown[index]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!InlineParser.TryParseDestinationAndTitle(markdown, colonIndex + 1, out var destination, out var title, out position))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var link = new LinkReference { Destination = destination, Title = title };
|
||||
|
||||
var key = id.ToString().ToLowerInvariant();
|
||||
|
||||
if (!linkReferences.ContainsKey(key))
|
||||
{
|
||||
linkReferences[key] = link;
|
||||
}
|
||||
|
||||
newIndex = position;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
enum BlockStart
|
||||
{
|
||||
Skip,
|
||||
Container,
|
||||
Leaf
|
||||
}
|
||||
64
Radzen.Blazor/Markdown/BlockQuote.cs
Normal file
64
Radzen.Blazor/Markdown/BlockQuote.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Represents a markdown block quote: <c>> Quote</c>.
|
||||
/// </summary>
|
||||
public class BlockQuote : BlockContainer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitBlockQuote(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanContain(Block node)
|
||||
{
|
||||
return node is not ListItem;
|
||||
}
|
||||
|
||||
internal override BlockMatch Matches(BlockParser parser)
|
||||
{
|
||||
if (!parser.Indented && parser.PeekNonSpace() == '>')
|
||||
{
|
||||
parser.AdvanceNextNonSpace();
|
||||
parser.AdvanceOffset(1, false);
|
||||
// optional following space
|
||||
|
||||
if (parser.Peek().IsSpaceOrTab())
|
||||
{
|
||||
parser.AdvanceOffset(1, true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return BlockMatch.Skip;
|
||||
}
|
||||
|
||||
return BlockMatch.Match;
|
||||
}
|
||||
|
||||
internal static BlockStart Start(BlockParser parser, Block block)
|
||||
{
|
||||
if (!parser.Indented && parser.PeekNonSpace() == '>')
|
||||
{
|
||||
parser.AdvanceNextNonSpace();
|
||||
parser.AdvanceOffset(1, false);
|
||||
// optional following space
|
||||
|
||||
if (parser.Peek().IsSpaceOrTab())
|
||||
{
|
||||
parser.AdvanceOffset(1, true);
|
||||
}
|
||||
|
||||
parser.CloseUnmatchedBlocks();
|
||||
|
||||
parser.AddChild<BlockQuote>(parser.NextNonSpace);
|
||||
|
||||
return BlockStart.Container;
|
||||
}
|
||||
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
}
|
||||
10
Radzen.Blazor/Markdown/CharExtensions.cs
Normal file
10
Radzen.Blazor/Markdown/CharExtensions.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
static class CharExtensions
|
||||
{
|
||||
public static bool IsNullOrWhiteSpace(this char ch) => ch == '\0' || char.IsWhiteSpace(ch);
|
||||
|
||||
public static bool IsPunctuation(this char ch) => char.IsPunctuation(ch);
|
||||
|
||||
public static bool IsSpaceOrTab(this char ch) => ch == ' ' || ch == '\t';
|
||||
}
|
||||
19
Radzen.Blazor/Markdown/Code.cs
Normal file
19
Radzen.Blazor/Markdown/Code.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a markdown inline code block: <c>`code`</c>.
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
public class Code(string value) : Inline
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the code value.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitCode(this);
|
||||
}
|
||||
}
|
||||
35
Radzen.Blazor/Markdown/Document.cs
Normal file
35
Radzen.Blazor/Markdown/Document.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a markdown document.
|
||||
/// </summary>
|
||||
public class Document : BlockContainer
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Document"/> class.
|
||||
/// </summary>
|
||||
public Document()
|
||||
{
|
||||
Range.Start.Line = 1;
|
||||
Range.Start.Column = 1;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitDocument(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanContain(Block node)
|
||||
{
|
||||
return node is not ListItem;
|
||||
}
|
||||
|
||||
internal override void Close(BlockParser parser)
|
||||
{
|
||||
base.Close(parser);
|
||||
|
||||
LinkReferenceParser.Parse(parser, this);
|
||||
}
|
||||
}
|
||||
13
Radzen.Blazor/Markdown/Emphasis.cs
Normal file
13
Radzen.Blazor/Markdown/Emphasis.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an emphasis element in a markdown document: <c>_emphasis_</c> or <c>*emphasis*</c>.
|
||||
/// </summary>
|
||||
public class Emphasis : InlineContainer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitEmphasis(this);
|
||||
}
|
||||
}
|
||||
98
Radzen.Blazor/Markdown/FencedCodeBlock.cs
Normal file
98
Radzen.Blazor/Markdown/FencedCodeBlock.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a fenced code block in a markdown document: <c>```</c> or <c>~~~</c>.
|
||||
/// </summary>
|
||||
public class FencedCodeBlock : Leaf
|
||||
{
|
||||
/// <summary>
|
||||
/// The delimiter used to start and end the code block.
|
||||
/// </summary>
|
||||
public string Delimiter { get; private set; }
|
||||
internal int Indent { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The info string of the code block. This is the first line of the code block and is used to specify the language of the code block.
|
||||
/// </summary>
|
||||
public string Info { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitFencedCodeBlock(this);
|
||||
}
|
||||
|
||||
internal override void Close(BlockParser parser)
|
||||
{
|
||||
base.Close(parser);
|
||||
|
||||
// first line becomes info string
|
||||
var newlinePos = Value.IndexOf('\n');
|
||||
var firstLine = Value[..newlinePos];
|
||||
Info = firstLine.Trim();
|
||||
Value = Value[(newlinePos + 1)..];
|
||||
}
|
||||
|
||||
internal override BlockMatch Matches(BlockParser parser)
|
||||
{
|
||||
var line = parser.CurrentLine[parser.NextNonSpace..];
|
||||
|
||||
var indent = parser.Indent;
|
||||
|
||||
var match = ClosingFenceRegex.Match(line);
|
||||
|
||||
if (indent <= 3 && parser.PeekNonSpace() == Delimiter[0] && match.Success && match.Length >= Delimiter.Length)
|
||||
{
|
||||
// closing fence - we're at end of line, so we can return
|
||||
parser.LastLineLength = parser.Offset + indent + match.Length;
|
||||
parser.Close(this, parser.LineNumber);
|
||||
return BlockMatch.Break;
|
||||
}
|
||||
else
|
||||
{
|
||||
// skip optional spaces of fence offset
|
||||
var i = Indent;
|
||||
|
||||
while (i > 0 && parser.Peek().IsSpaceOrTab())
|
||||
{
|
||||
parser.AdvanceOffset(1, true);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
return BlockMatch.Match;
|
||||
}
|
||||
|
||||
|
||||
private static readonly Regex ClosingFenceRegex = new(@"^(?:`{3,}|~{3,})(?=[ \t]*$)");
|
||||
|
||||
private static readonly Regex OpeningFenceRegex = new(@"^`{3,}(?!.*`)|^~{3,}");
|
||||
|
||||
internal static BlockStart Start(BlockParser parser, Block node)
|
||||
{
|
||||
if (parser.Indented)
|
||||
{
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
|
||||
var line = parser.CurrentLine[parser.NextNonSpace..];
|
||||
|
||||
var match = OpeningFenceRegex.Match(line);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
parser.CloseUnmatchedBlocks();
|
||||
|
||||
var container = parser.AddChild<FencedCodeBlock>(parser.NextNonSpace);
|
||||
container.Delimiter = match.Value;
|
||||
container.Indent = parser.Indent;
|
||||
parser.AdvanceNextNonSpace();
|
||||
parser.AdvanceOffset(match.Value.Length, false);
|
||||
return BlockStart.Leaf;
|
||||
}
|
||||
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
}
|
||||
25
Radzen.Blazor/Markdown/Heading.cs
Normal file
25
Radzen.Blazor/Markdown/Heading.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// A base class for all heading elements.
|
||||
/// </summary>
|
||||
public abstract class Heading : Leaf
|
||||
{
|
||||
/// <summary>
|
||||
/// The level of the heading. The value is between 1 and 6.
|
||||
/// </summary>
|
||||
public int Level { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitHeading(this);
|
||||
}
|
||||
|
||||
internal override BlockMatch Matches(BlockParser parser)
|
||||
{
|
||||
// a heading can never container another line
|
||||
return BlockMatch.Skip;
|
||||
}
|
||||
}
|
||||
56
Radzen.Blazor/Markdown/HtmlBlock.cs
Normal file
56
Radzen.Blazor/Markdown/HtmlBlock.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an HTML block.
|
||||
/// </summary>
|
||||
public class HtmlBlock : Leaf
|
||||
{
|
||||
internal int Type { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitHtmlBlock(this);
|
||||
}
|
||||
|
||||
internal override BlockMatch Matches(BlockParser parser)
|
||||
{
|
||||
return parser.Blank && (Type == 6 || Type == 7) ? BlockMatch.Skip : BlockMatch.Match;
|
||||
}
|
||||
|
||||
private static readonly Regex TrailinNewLineRegex = new(@"\n$");
|
||||
|
||||
internal override void Close(BlockParser parser)
|
||||
{
|
||||
base.Close(parser);
|
||||
|
||||
Value = TrailinNewLineRegex.Replace(Value, "");
|
||||
}
|
||||
|
||||
internal static BlockStart Start(BlockParser parser, Block node)
|
||||
{
|
||||
if (!parser.Indented && parser.PeekNonSpace() == '<')
|
||||
{
|
||||
var line = parser.CurrentLine[parser.NextNonSpace..];
|
||||
|
||||
for (var blockType = 1; blockType <= 7; blockType++) {
|
||||
|
||||
if (BlockParser.HtmlBlockOpenRegex[blockType].IsMatch(line) &&
|
||||
(blockType < 7 || (node is not Paragraph &&
|
||||
!(!parser.AllClosed && !parser.Blank && parser.Tip is Paragraph) // maybe lazy
|
||||
))) {
|
||||
parser.CloseUnmatchedBlocks();
|
||||
// We don't adjust parser.offset;
|
||||
// spaces are part of the HTML block:
|
||||
var block = parser.AddChild<HtmlBlock>(parser.Offset);
|
||||
block.Type = blockType;
|
||||
return BlockStart.Leaf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
}
|
||||
19
Radzen.Blazor/Markdown/HtmlInline.cs
Normal file
19
Radzen.Blazor/Markdown/HtmlInline.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an inline HTML element.
|
||||
/// </summary>
|
||||
public class HtmlInline : Inline
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the HTML element value.
|
||||
/// </summary>
|
||||
public string Value { get; set; }
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitHtmlInline(this);
|
||||
}
|
||||
}
|
||||
136
Radzen.Blazor/Markdown/HtmlSanitizer.cs
Normal file
136
Radzen.Blazor/Markdown/HtmlSanitizer.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
#nullable enable
|
||||
class HtmlSanitizer
|
||||
{
|
||||
private readonly ISet<string> allowedTags;
|
||||
private readonly ISet<string> allowedAttributes;
|
||||
|
||||
public HtmlSanitizer(IEnumerable<string>? allowedHtmlTags, IEnumerable<string>? allowedHtmlAttributes)
|
||||
{
|
||||
allowedTags = allowedHtmlTags != null ? new HashSet<string>(allowedHtmlTags) : AllowedTags;
|
||||
allowedAttributes = allowedHtmlAttributes != null ? new HashSet<string>(allowedHtmlAttributes) : AllowedAttributes;
|
||||
}
|
||||
|
||||
private static ISet<string> AllowedTags { get; } = new HashSet<string>()
|
||||
{
|
||||
// https://developer.mozilla.org/en/docs/Web/Guide/HTML/HTML5/HTML5_element_list
|
||||
"a", "abbr", "acronym", "address", "area", "b",
|
||||
"big", "blockquote", "br", "button", "caption", "center", "cite",
|
||||
"code", "col", "colgroup", "dd", "del", "dfn", "dir", "div", "dl", "dt",
|
||||
"em", "fieldset", "font", "form", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"hr", "i", "img", "input", "ins", "kbd", "label", "legend", "li", "map",
|
||||
"menu", "ol", "optgroup", "option", "p", "pre", "q", "s", "samp",
|
||||
"select", "small", "span", "strike", "strong", "sub", "sup", "table",
|
||||
"tbody", "td", "textarea", "tfoot", "th", "thead", "tr", "tt", "u",
|
||||
"ul", "var", "section", "nav", "article", "aside", "header", "footer", "main",
|
||||
"figure", "figcaption", "data", "time", "mark", "ruby", "rt", "rp", "bdi", "wbr",
|
||||
"datalist", "keygen", "output", "progress", "meter", "details", "summary", "menuitem",
|
||||
"html", "head", "body"
|
||||
};
|
||||
|
||||
public static ISet<string> AllowedAttributes { get; } = new HashSet<string>()
|
||||
{
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes
|
||||
"abbr", "accept", "accept-charset", "accesskey",
|
||||
"action", "align", "alt", "axis", "bgcolor", "border", "cellpadding",
|
||||
"cellspacing", "char", "charoff", "charset", "checked", "cite", "class",
|
||||
"clear", "cols", "colspan", "color", "compact", "coords", "datetime",
|
||||
"dir", "disabled", "enctype", "for", "frame", "headers", "height",
|
||||
"href", "hreflang", "hspace", "id", "ismap", "label", "lang",
|
||||
"longdesc", "maxlength", "media", "method", "multiple", "name",
|
||||
"nohref", "noshade", "nowrap", "prompt", "readonly", "rel", "rev",
|
||||
"rows", "rowspan", "rules", "scope", "selected", "shape", "size",
|
||||
"span", "src", "start", "style", "summary", "tabindex", "target", "title",
|
||||
"type", "usemap", "valign", "value", "vspace", "width",
|
||||
"high", "keytype", "list", "low", "max", "min", "novalidate", "open", "optimum",
|
||||
"pattern", "placeholder", "pubdate", "radiogroup", "required", "reversed", "spellcheck", "step",
|
||||
"wrap", "challenge", "contenteditable", "draggable", "dropzone", "autocomplete", "autosave",
|
||||
};
|
||||
|
||||
public static ISet<string> UriAttributes { get; } = new HashSet<string>()
|
||||
{
|
||||
"action", "background", "dynsrc", "href", "lowsrc", "src"
|
||||
};
|
||||
|
||||
public string Sanitize(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Regex.Replace(input, @"</?([a-zA-Z0-9]+)(\s[^>]*)?>", SanitizeTag);
|
||||
}
|
||||
|
||||
private string SanitizeTag(Match match)
|
||||
{
|
||||
var tag = match.Groups[1].Value.ToLowerInvariant();
|
||||
|
||||
if (!allowedTags.Contains(tag))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var attributes = match.Groups[2].Value;
|
||||
|
||||
var safeAttributes = Regex.Replace(attributes, @"(\w+)\s*=\s*(""[^""]*""|'[^']*'|[^\s>]+)", SanitizeAttribute);
|
||||
|
||||
return $"<{(match.Value.StartsWith("</") ? "/" : "")}{tag}{safeAttributes}>";
|
||||
}
|
||||
|
||||
private string SanitizeAttribute(Match match)
|
||||
{
|
||||
var name = match.Groups[1].Value.ToLowerInvariant();
|
||||
var value = match.Groups[2].Value;
|
||||
|
||||
if (!allowedAttributes.Contains(name))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (name == "style")
|
||||
{
|
||||
var decoded = HtmlDecode(value).ToLowerInvariant();
|
||||
|
||||
if (Regex.IsMatch(decoded, @"expression|javascript:|vbscript:|url\s*\(\s*(['""])?\s*javascript", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
if (UriAttributes.Contains(name) && IsDangerousUrl(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if ((value.StartsWith('\'') && value.EndsWith('\'')) || (value.StartsWith('"') && value.EndsWith('"')))
|
||||
{
|
||||
value = value[1..^1];
|
||||
}
|
||||
|
||||
return $" {name}=\"{value}\"";
|
||||
}
|
||||
|
||||
private static string HtmlDecode(string input)
|
||||
{
|
||||
return System.Web.HttpUtility.HtmlDecode(input);
|
||||
}
|
||||
|
||||
public static bool IsDangerousUrl(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var decoded = HtmlDecode(value).Trim().ToLowerInvariant();
|
||||
|
||||
return decoded.StartsWith("javascript:") ||
|
||||
decoded.StartsWith("vbscript:") ||
|
||||
decoded.StartsWith("data:text/html") ||
|
||||
decoded.Contains("expression(");
|
||||
}
|
||||
}
|
||||
25
Radzen.Blazor/Markdown/IBlockInlineContainer.cs
Normal file
25
Radzen.Blazor/Markdown/IBlockInlineContainer.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a block node that has inline children.
|
||||
/// </summary>
|
||||
public interface IBlockInlineContainer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the inline children of the block.
|
||||
/// </summary>
|
||||
IReadOnlyList<Inline> Children { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds an inline child to the block.
|
||||
/// </summary>
|
||||
/// <param name="child"></param>
|
||||
void Add(Inline child);
|
||||
|
||||
/// <summary>
|
||||
/// Gets string value of the block.
|
||||
/// </summary>
|
||||
string Value { get; }
|
||||
}
|
||||
15
Radzen.Blazor/Markdown/INode.cs
Normal file
15
Radzen.Blazor/Markdown/INode.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a markdown node that can be visited by a <see cref="INodeVisitor"/>.
|
||||
/// </summary>
|
||||
public interface INode
|
||||
{
|
||||
/// <summary>
|
||||
/// Accepts a <see cref="INodeVisitor"/>.
|
||||
/// </summary>
|
||||
/// <param name="visitor"></param>
|
||||
public void Accept(INodeVisitor visitor);
|
||||
}
|
||||
128
Radzen.Blazor/Markdown/INodeVisitor.cs
Normal file
128
Radzen.Blazor/Markdown/INodeVisitor.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Represents a visitor for Markdown AST nodes.
|
||||
/// </summary>
|
||||
public interface INodeVisitor
|
||||
{
|
||||
/// <summary>
|
||||
/// Visits a heading node.
|
||||
/// </summary>
|
||||
void VisitHeading(Heading heading);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a paragraph node.
|
||||
/// </summary>
|
||||
void VisitParagraph(Paragraph paragraph);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a block quote node.
|
||||
/// </summary>
|
||||
void VisitBlockQuote(BlockQuote blockQuote);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a document node.
|
||||
/// </summary>
|
||||
void VisitDocument(Document document);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an unordered list node.
|
||||
/// </summary>
|
||||
void VisitUnorderedList(UnorderedList unorderedList);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a list item node.
|
||||
/// </summary>
|
||||
void VisitListItem(ListItem listItem);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a text node.
|
||||
/// </summary>
|
||||
void VisitText(Text text);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an ordered list node.
|
||||
/// </summary>
|
||||
void VisitOrderedList(OrderedList orderedList);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an emphasis node.
|
||||
/// </summary>
|
||||
void VisitEmphasis(Emphasis emphasis);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a strong node.
|
||||
/// </summary>
|
||||
void VisitStrong(Strong strong);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a code node.
|
||||
/// </summary>
|
||||
void VisitCode(Code code);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a link node.
|
||||
/// </summary>
|
||||
void VisitLink(Link link);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an image node.
|
||||
/// </summary>
|
||||
void VisitImage(Image image);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an HTML inline node.
|
||||
/// </summary>
|
||||
void VisitHtmlInline(HtmlInline html);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a line break node.
|
||||
/// </summary>
|
||||
void VisitLineBreak(LineBreak lineBreak);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a soft line break node.
|
||||
/// </summary>
|
||||
void VisitSoftLineBreak(SoftLineBreak softLineBreak);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a thematic break node.
|
||||
/// </summary>
|
||||
void VisitThematicBreak(ThematicBreak thematicBreak);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an indented code block node.
|
||||
/// </summary>
|
||||
void VisitIndentedCodeBlock(IndentedCodeBlock codeBlock);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a fenced code block node.
|
||||
/// </summary>
|
||||
void VisitFencedCodeBlock(FencedCodeBlock fencedCodeBlock);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an HTML block node.
|
||||
/// </summary>
|
||||
void VisitHtmlBlock(HtmlBlock htmlBlock);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a table node.
|
||||
/// </summary>
|
||||
void VisitTable(Table table);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a table header row node.
|
||||
/// </summary>
|
||||
void VisitTableHeaderRow(TableHeaderRow header);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a table row node.
|
||||
/// </summary>
|
||||
void VisitTableRow(TableRow row);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a table cell node.
|
||||
/// </summary>
|
||||
void VisitTableCell(TableCell cell);
|
||||
}
|
||||
23
Radzen.Blazor/Markdown/Image.cs
Normal file
23
Radzen.Blazor/Markdown/Image.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an inline image element: <c></c>
|
||||
/// </summary>
|
||||
public class Image : InlineContainer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the destination (URL) of the image.
|
||||
/// </summary>
|
||||
public string Destination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the alternative text of the image.
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitImage(this);
|
||||
}
|
||||
}
|
||||
69
Radzen.Blazor/Markdown/IndentedCodeBlock.cs
Normal file
69
Radzen.Blazor/Markdown/IndentedCodeBlock.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a markdown indented code block.
|
||||
/// </summary>
|
||||
public class IndentedCodeBlock : Leaf
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitIndentedCodeBlock(this);
|
||||
}
|
||||
|
||||
internal override BlockMatch Matches(BlockParser parser)
|
||||
{
|
||||
if (parser.Indent >= BlockParser.CodeIndent)
|
||||
{
|
||||
parser.AdvanceOffset(BlockParser.CodeIndent, true);
|
||||
}
|
||||
else if (parser.Blank)
|
||||
{
|
||||
parser.AdvanceNextNonSpace();
|
||||
}
|
||||
else
|
||||
{
|
||||
return BlockMatch.Skip;
|
||||
}
|
||||
|
||||
return BlockMatch.Match;
|
||||
}
|
||||
|
||||
private static readonly Regex TrailingWhiteSpaceRegex = new(@"^[ \t]*$");
|
||||
|
||||
internal override void Close(BlockParser parser)
|
||||
{
|
||||
base.Close(parser);
|
||||
|
||||
var lines = Value.Split('\n').ToList();;
|
||||
// Note that indented code block cannot be empty, so
|
||||
// lines.length cannot be zero.
|
||||
|
||||
while (TrailingWhiteSpaceRegex.IsMatch(lines[^1]))
|
||||
{
|
||||
lines.RemoveAt(lines.Count - 1);
|
||||
}
|
||||
|
||||
Value = string.Join('\n', lines) + '\n';
|
||||
|
||||
Range.End.Line = Range.Start.Line + lines.Count - 1;
|
||||
Range.End.Column = Range.Start.Column + lines[^1].Length - 1;
|
||||
}
|
||||
|
||||
internal static BlockStart Start(BlockParser parser, Block container)
|
||||
{
|
||||
if (parser.Indented && parser.Tip is not Paragraph && !parser.Blank)
|
||||
{
|
||||
// indented code
|
||||
parser.AdvanceOffset(BlockParser.CodeIndent, true);
|
||||
parser.CloseUnmatchedBlocks();
|
||||
parser.AddChild<IndentedCodeBlock>(parser.Offset);
|
||||
return BlockStart.Leaf;
|
||||
}
|
||||
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
}
|
||||
12
Radzen.Blazor/Markdown/Inline.cs
Normal file
12
Radzen.Blazor/Markdown/Inline.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for markdown inline nodes.
|
||||
/// </summary>
|
||||
public abstract class Inline : INode
|
||||
{
|
||||
/// <summary>
|
||||
/// Accepts a visitor.
|
||||
/// </summary>
|
||||
public abstract void Accept(INodeVisitor visitor);
|
||||
}
|
||||
25
Radzen.Blazor/Markdown/InlineContainer.cs
Normal file
25
Radzen.Blazor/Markdown/InlineContainer.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for inline elements that contain other inline elements.
|
||||
/// </summary>
|
||||
public abstract class InlineContainer : Inline
|
||||
{
|
||||
private readonly List<Inline> children = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the children of the container.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Inline> Children => children;
|
||||
|
||||
/// <summary>
|
||||
/// Appends a child to the container.
|
||||
/// </summary>
|
||||
/// <param name="node">The child to add.</param>
|
||||
public void Add(Inline node)
|
||||
{
|
||||
children.Add(node);
|
||||
}
|
||||
}
|
||||
959
Radzen.Blazor/Markdown/InlineParser.cs
Normal file
959
Radzen.Blazor/Markdown/InlineParser.cs
Normal file
@@ -0,0 +1,959 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
class InlineParser
|
||||
{
|
||||
class Delimiter
|
||||
{
|
||||
public char Char { get; set; }
|
||||
public int Length { get; set; }
|
||||
public int Position { get; set; }
|
||||
public Text Node { get; set; }
|
||||
public bool CanOpen { get; set; }
|
||||
public bool CanClose { get; set; }
|
||||
public bool Active { get; set; } = true;
|
||||
}
|
||||
|
||||
private const char Asterisk = '*';
|
||||
private const char Underscore = '_';
|
||||
internal const char Backslash = '\\';
|
||||
private const char Null = '\0';
|
||||
private const char Backtick = '`';
|
||||
internal const char Space = ' ';
|
||||
internal const char LineFeed = '\n';
|
||||
private const char CarrigeReturn = '\r';
|
||||
internal const char OpenBracket = '[';
|
||||
internal const char CloseBracket = ']';
|
||||
private const char OpenParenthesis = '(';
|
||||
private const char CloseParenthesis = ')';
|
||||
internal const char Quote = '"';
|
||||
internal const char OpenAngleBracket = '<';
|
||||
internal const char CloseAngleBracket = '>';
|
||||
private const char SingleQuote = '\'';
|
||||
private const char Exclamation = '!';
|
||||
internal const char Colon = ':';
|
||||
|
||||
private readonly List<Inline> inlines = [];
|
||||
private readonly List<Delimiter> delimiters = [];
|
||||
private readonly StringBuilder buffer = new();
|
||||
|
||||
enum LinkState
|
||||
{
|
||||
Text,
|
||||
Destination,
|
||||
Title
|
||||
}
|
||||
|
||||
private void AddTextNode(bool trim = false)
|
||||
{
|
||||
if (buffer.Length > 0)
|
||||
{
|
||||
var output = new StringBuilder();
|
||||
|
||||
for (var index = 0; index < buffer.Length; index++)
|
||||
{
|
||||
var ch = buffer[index];
|
||||
|
||||
if (ch is Backslash && index < buffer.Length - 1 && !buffer[index + 1].IsPunctuation())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
output.Append(ch);
|
||||
}
|
||||
}
|
||||
var value = output.ToString();
|
||||
|
||||
if (trim)
|
||||
{
|
||||
value = value.TrimEnd();
|
||||
}
|
||||
|
||||
inlines.Add(new Text(value));
|
||||
|
||||
buffer.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryParseCode(string text, int index, out int newIndex)
|
||||
{
|
||||
if (text[index] is not Backtick)
|
||||
{
|
||||
newIndex = index;
|
||||
return false;
|
||||
}
|
||||
|
||||
AddTextNode();
|
||||
|
||||
// Count opening backticks
|
||||
var openingCount = 0;
|
||||
var position = index;
|
||||
while (position < text.Length && text[position] is Backtick)
|
||||
{
|
||||
openingCount++;
|
||||
position++;
|
||||
}
|
||||
|
||||
// Find matching closing backticks
|
||||
var searchStart = position;
|
||||
var bestMatch = -1;
|
||||
|
||||
while (position < text.Length)
|
||||
{
|
||||
// Count consecutive backticks
|
||||
var count = 0;
|
||||
var closingStart = position;
|
||||
while (position < text.Length && text[position] is Backtick)
|
||||
{
|
||||
count++;
|
||||
position++;
|
||||
}
|
||||
|
||||
if (count == openingCount)
|
||||
{
|
||||
bestMatch = closingStart;
|
||||
break;
|
||||
}
|
||||
|
||||
if (position < text.Length)
|
||||
{
|
||||
position++;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch >= 0)
|
||||
{
|
||||
var content = text[searchStart..bestMatch];
|
||||
|
||||
content = BlockParser.NewLineRegex.Replace(content, $"{Space}");
|
||||
|
||||
if (content.StartsWith(Space) && content.EndsWith(Space) && !string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
content = content[1..^1];
|
||||
}
|
||||
|
||||
inlines.Add(new Code(content));
|
||||
|
||||
newIndex = bestMatch + openingCount;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inlines.Add(new Text($"{new string(Backtick, openingCount)}"));
|
||||
|
||||
newIndex = index + openingCount;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryParseBackslash(string text, int index, char next, out int newIndex)
|
||||
{
|
||||
if (text[index] is not Backslash)
|
||||
{
|
||||
newIndex = index;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next.IsPunctuation())
|
||||
{
|
||||
AddTextNode();
|
||||
inlines.Add(new Text(text[index + 1].ToString()));
|
||||
newIndex = index + 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
buffer.Append(text[index]);
|
||||
newIndex = index + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryParseDelimiter(string text, int index, char next, char prev, out int newIndex)
|
||||
{
|
||||
var ch = text[index];
|
||||
|
||||
if (ch is not (Asterisk or Underscore or OpenBracket) && (ch is not Exclamation || next is not OpenBracket))
|
||||
{
|
||||
newIndex = index;
|
||||
return false;
|
||||
}
|
||||
|
||||
AddTextNode();
|
||||
|
||||
var position = index;
|
||||
|
||||
while (position < text.Length && text[position] == ch)
|
||||
{
|
||||
buffer.Append(ch);
|
||||
|
||||
position++;
|
||||
}
|
||||
|
||||
if (ch is Exclamation)
|
||||
{
|
||||
buffer.Append(OpenBracket);
|
||||
position++;
|
||||
}
|
||||
|
||||
next = position < text.Length ? text[position] : Null;
|
||||
|
||||
if (buffer.Length > 0)
|
||||
{
|
||||
var node = new Text(buffer.ToString());
|
||||
var leftFlanking = LeftFlanking(prev, next);
|
||||
var rightFlanking = RightFlanking(prev, next);
|
||||
|
||||
var canOpen = false;
|
||||
var canClose = false;
|
||||
|
||||
if (ch is Asterisk)
|
||||
{
|
||||
canOpen = leftFlanking;
|
||||
canClose = rightFlanking;
|
||||
}
|
||||
|
||||
if (ch is Underscore)
|
||||
{
|
||||
canClose = rightFlanking && (!leftFlanking || next.IsPunctuation());
|
||||
canOpen = leftFlanking && (!rightFlanking || prev.IsPunctuation());
|
||||
}
|
||||
|
||||
var delimiter = new Delimiter
|
||||
{
|
||||
Node = node,
|
||||
Char = ch,
|
||||
Length = buffer.Length,
|
||||
Position = index,
|
||||
CanClose = canClose,
|
||||
CanOpen = canOpen
|
||||
};
|
||||
|
||||
delimiters.Add(delimiter);
|
||||
inlines.Add(node);
|
||||
buffer.Clear();
|
||||
}
|
||||
|
||||
newIndex = position;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RightFlanking(char prev, char next)
|
||||
{
|
||||
/*
|
||||
that is (1) not preceded by Unicode whitespace, and either (2a) not preceded by a Unicode punctuation character,
|
||||
or (2b) preceded by a Unicode punctuation character and followed by Unicode whitespace or a Unicode punctuation character.
|
||||
*/
|
||||
|
||||
return !prev.IsNullOrWhiteSpace() && (!prev.IsPunctuation() || next.IsNullOrWhiteSpace() || next.IsPunctuation());
|
||||
}
|
||||
|
||||
private static bool LeftFlanking(char prev, char next)
|
||||
{
|
||||
/*
|
||||
that is (1) not followed by Unicode whitespace, and either (2a) not followed by a Unicode punctuation character,
|
||||
or (2b) followed by a Unicode punctuation character and preceded by Unicode whitespace or a Unicode punctuation character.
|
||||
*/
|
||||
|
||||
return !next.IsNullOrWhiteSpace() && (!next.IsPunctuation() || prev.IsNullOrWhiteSpace() || prev.IsPunctuation());
|
||||
}
|
||||
|
||||
public static List<Inline> Parse(string text, Dictionary<string, LinkReference> linkReferences)
|
||||
{
|
||||
var parser = new InlineParser();
|
||||
|
||||
return parser.ParseInlines(text.Trim(), linkReferences);
|
||||
}
|
||||
|
||||
private static readonly Regex EmailRegex = new(@"^([a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)");
|
||||
|
||||
private bool TryParseAutoLink(string text, int index, out int newIndex)
|
||||
{
|
||||
newIndex = index;
|
||||
|
||||
if (text[index] is not OpenAngleBracket)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destination = new StringBuilder();
|
||||
|
||||
var position = index + 1;
|
||||
|
||||
while (position < text.Length && text[position] is not CloseAngleBracket)
|
||||
{
|
||||
destination.Append(text[position]);
|
||||
position++;
|
||||
}
|
||||
|
||||
if (position >= text.Length || text[position] is not CloseAngleBracket)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var url = destination.ToString();
|
||||
|
||||
if (url.Contains(Space))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var content = url;
|
||||
|
||||
if (EmailRegex.IsMatch(url))
|
||||
{
|
||||
url = $"mailto:{url}";
|
||||
}
|
||||
else if (!Uri.TryCreate(url, UriKind.Absolute, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var link = new Link { Destination = url };
|
||||
|
||||
link.Add(new Text(content));
|
||||
|
||||
inlines.Add(link);
|
||||
|
||||
newIndex = position + 1;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private List<Inline> ParseInlines(string text, Dictionary<string, LinkReference> references)
|
||||
{
|
||||
var index = 0;
|
||||
|
||||
while (index < text.Length)
|
||||
{
|
||||
if (TryParseHtml(text, index, out index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseAutoLink(text, index, out index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseCode(text, index, out index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseLineBreak(text, index, out index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseSoftLineBreak(text, index, out index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
char next = index < text.Length - 1 ? text[index + 1] : Null;
|
||||
|
||||
if (TryParseBackslash(text, index, next, out index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
char prev = index > 0 ? text[index - 1] : Null;
|
||||
|
||||
if (TryParseDelimiter(text, index, next, prev, out index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseLinkFromReference(text, index, references, out index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseLinkOrImage(text, index, out index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.Append(text[index]);
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
AddTextNode();
|
||||
|
||||
ParseEmphasisAndStrong();
|
||||
|
||||
NormalizeText();
|
||||
|
||||
return inlines;
|
||||
}
|
||||
|
||||
private void NormalizeText()
|
||||
{
|
||||
if (inlines.Count > 0)
|
||||
{
|
||||
if (inlines[0] is Text first)
|
||||
{
|
||||
first.Value = first.Value.TrimStart();
|
||||
}
|
||||
|
||||
if (inlines[^1] is Text last)
|
||||
{
|
||||
last.Value = last.Value.TrimEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryParseSoftLineBreak(string text, int index, out int newIndex)
|
||||
{
|
||||
newIndex = index;
|
||||
|
||||
if (TryParseNewLine(text, index, out var position))
|
||||
{
|
||||
AddTextNode(trim: true);
|
||||
|
||||
inlines.Add(new SoftLineBreak());
|
||||
|
||||
newIndex = position;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryParseLineBreak(string text, int index, out int newIndex)
|
||||
{
|
||||
newIndex = index;
|
||||
|
||||
if (text[index] is not Space && text[index] is not Backslash)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var position = index + 1;
|
||||
|
||||
if (position < text.Length && text[position] is Space)
|
||||
{
|
||||
while (position < text.Length && text[position] is Space)
|
||||
{
|
||||
position++;
|
||||
}
|
||||
}
|
||||
|
||||
if (text[index] is Space && position == index + 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (position < text.Length && TryParseNewLine(text, position, out position))
|
||||
{
|
||||
AddTextNode(trim: true);
|
||||
|
||||
inlines.Add(new LineBreak());
|
||||
|
||||
newIndex = position;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseNewLine(string text, int position, out int newIndex)
|
||||
{
|
||||
newIndex = position;
|
||||
|
||||
if (position >= text.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text[position] is LineFeed)
|
||||
{
|
||||
newIndex = position + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text[position] is CarrigeReturn && position < text.Length - 1 && text[position + 1] is LineFeed)
|
||||
{
|
||||
newIndex = position + 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryParseHtml(string text, int index, out int newIndex)
|
||||
{
|
||||
newIndex = index;
|
||||
|
||||
var match = BlockParser.HtmlRegex.Match(text[index..]);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
AddTextNode();
|
||||
|
||||
var value = text[index..(index + match.Length)];
|
||||
|
||||
inlines.Add(new HtmlInline { Value = value });
|
||||
|
||||
newIndex = index + match.Length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool TryParseDestinationAndTitle(string text, int position, out string destination, out string title, out int newPosition)
|
||||
{
|
||||
newPosition = position;
|
||||
destination = string.Empty;
|
||||
title = string.Empty;
|
||||
|
||||
// Skip whitespace
|
||||
while (position < text.Length && text[position] is Space or LineFeed)
|
||||
{
|
||||
position++;
|
||||
}
|
||||
|
||||
if (position >= text.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse destination
|
||||
var destinationBuilder = new StringBuilder();
|
||||
|
||||
var angleBrackets = position < text.Length && text[position] is OpenAngleBracket;
|
||||
|
||||
if (angleBrackets)
|
||||
{
|
||||
position++;
|
||||
}
|
||||
|
||||
var parentheses = 0;
|
||||
|
||||
while (position < text.Length)
|
||||
{
|
||||
var ch = text[position];
|
||||
var prev = position > 0 ? text[position - 1] : Null;
|
||||
var next = position < text.Length - 1 ? text[position + 1] : Null;
|
||||
|
||||
if (angleBrackets && ch is CloseAngleBracket && prev is not Backslash)
|
||||
{
|
||||
position++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!angleBrackets)
|
||||
{
|
||||
if (ch is OpenParenthesis && prev is not Backslash)
|
||||
{
|
||||
parentheses++;
|
||||
}
|
||||
else if (ch is CloseParenthesis && prev is not Backslash)
|
||||
{
|
||||
if (parentheses == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
parentheses--;
|
||||
}
|
||||
|
||||
if (ch is Space or LineFeed)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch is Backslash && next.IsPunctuation())
|
||||
{
|
||||
position++;
|
||||
continue;
|
||||
}
|
||||
|
||||
destinationBuilder.Append(ch);
|
||||
position++;
|
||||
}
|
||||
|
||||
if (angleBrackets)
|
||||
{
|
||||
// Skip whitespace after angle brackets
|
||||
while (position < text.Length && text[position] is Space)
|
||||
{
|
||||
position++;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse title if present
|
||||
var titleBuilder = new StringBuilder();
|
||||
if (position < text.Length && text[position].IsNullOrWhiteSpace())
|
||||
{
|
||||
var lines = 0;
|
||||
|
||||
while (position < text.Length && text[position].IsNullOrWhiteSpace())
|
||||
{
|
||||
if (text[position] is LineFeed)
|
||||
{
|
||||
lines++;
|
||||
}
|
||||
|
||||
position++;
|
||||
}
|
||||
|
||||
var titleStart = position;
|
||||
|
||||
if (position < text.Length)
|
||||
{
|
||||
var titleDelimiter = text[position];
|
||||
|
||||
if (titleDelimiter is Quote or SingleQuote or OpenParenthesis)
|
||||
{
|
||||
position++; // Skip opening delimiter
|
||||
|
||||
char closingDelimiter = titleDelimiter is OpenParenthesis ? CloseParenthesis : titleDelimiter;
|
||||
|
||||
while (position < text.Length && (text[position] != closingDelimiter || text[position - 1] is Backslash))
|
||||
{
|
||||
if (text[position] is LineFeed && titleBuilder.Length > 0 && titleBuilder[^1] is LineFeed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
titleBuilder.Append(text[position]);
|
||||
position++;
|
||||
}
|
||||
|
||||
if (position < text.Length && text[position] == closingDelimiter)
|
||||
{
|
||||
position++; // Skip closing delimiter
|
||||
|
||||
// Skip whitespace after title
|
||||
while (position < text.Length && text[position] is Space)
|
||||
{
|
||||
position++;
|
||||
}
|
||||
|
||||
if (position < text.Length)
|
||||
{
|
||||
if (position < text.Length && text[position] is not (CloseParenthesis or LineFeed))
|
||||
{
|
||||
if (lines > 0)
|
||||
{
|
||||
// non-white space characters after title
|
||||
newPosition = titleStart;
|
||||
destination = destinationBuilder.ToString();
|
||||
title = string.Empty;
|
||||
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (position < text.Length)
|
||||
{
|
||||
if (text[position] is LineFeed)
|
||||
{
|
||||
position++;
|
||||
}
|
||||
else if (text[position] is Quote or SingleQuote or OpenParenthesis)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
destination = destinationBuilder.ToString();
|
||||
title = titleBuilder.ToString();
|
||||
newPosition = position;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryParseLinkOrImage(string text, int index, out int newIndex)
|
||||
{
|
||||
newIndex = index;
|
||||
|
||||
if (!TryGetOpenerIndex(text, index, out var openerIndex, out var position))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseDestinationAndTitle(text, position, out var destination, out var title, out position))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (position >= text.Length || text[position] is not CloseParenthesis)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var opener = delimiters[openerIndex];
|
||||
|
||||
InlineContainer container = opener.Char == Exclamation ? new Image { Destination = destination, Title = title } : new Link { Destination = destination, Title = title };
|
||||
|
||||
ReplaceOpener(openerIndex, container);
|
||||
|
||||
newIndex = position + 1;
|
||||
|
||||
if (container is Link)
|
||||
{
|
||||
for (var delimiterIndex = 0; delimiterIndex < openerIndex; delimiterIndex++)
|
||||
{
|
||||
if (delimiters[delimiterIndex].Char == OpenBracket)
|
||||
{
|
||||
delimiters[delimiterIndex].Active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delimiters.Remove(opener);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
private bool TryGetOpenerIndex(string text, int index, out int openerIndex, out int position)
|
||||
{
|
||||
position = index;
|
||||
openerIndex = -1;
|
||||
|
||||
if (text[index] is not CloseBracket)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var di = delimiters.Count - 1;
|
||||
|
||||
while (di >= 0)
|
||||
{
|
||||
var delimiter = delimiters[di];
|
||||
|
||||
if ((delimiter.Active && delimiter.Char is OpenBracket) || delimiter.Char is Exclamation)
|
||||
{
|
||||
openerIndex = di;
|
||||
break;
|
||||
}
|
||||
|
||||
di--;
|
||||
}
|
||||
|
||||
if (di < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AddTextNode();
|
||||
|
||||
position = index + 1;
|
||||
|
||||
// Skip if not followed by opening parenthesis
|
||||
if (position >= text.Length || text[position] is not OpenParenthesis)
|
||||
{
|
||||
delimiters.RemoveAt(openerIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
position++; // Skip opening parenthesis
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ReplaceOpener(int openerIndex, InlineContainer parent)
|
||||
{
|
||||
var startIndex = inlines.FindIndex(delimiters[openerIndex].Node.Equals);
|
||||
|
||||
ParseEmphasisAndStrong(openerIndex);
|
||||
|
||||
var endIndex = inlines.Count - startIndex;
|
||||
|
||||
var children = inlines.GetRange(startIndex + 1, endIndex - 1);
|
||||
|
||||
inlines.RemoveRange(startIndex, endIndex);
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
parent.Add(child);
|
||||
}
|
||||
|
||||
inlines.Insert(startIndex, parent);
|
||||
}
|
||||
|
||||
|
||||
private bool TryParseLinkFromReference(string text, int index, Dictionary<string, LinkReference> references, out int newIndex)
|
||||
{
|
||||
newIndex = index;
|
||||
|
||||
if (references.Count == 0 || text[index] is not CloseBracket)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var openerIndex = FindOpenBracketIndex(OpenBracket);
|
||||
|
||||
if (openerIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AddTextNode();
|
||||
|
||||
var startIndex = inlines.FindIndex(delimiters[openerIndex].Node.Equals);
|
||||
|
||||
var endIndex = inlines.Count - startIndex;
|
||||
|
||||
var children = inlines.GetRange(startIndex + 1, endIndex - 1);
|
||||
|
||||
var id = new StringBuilder();
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
if (child is Text textNode)
|
||||
{
|
||||
id.Append(textNode.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!references.TryGetValue(id.ToString().ToLowerInvariant(), out var reference))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var link = new Link { Destination = reference.Destination, Title = reference.Title };
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
link.Add(child);
|
||||
}
|
||||
|
||||
inlines.RemoveRange(startIndex, endIndex);
|
||||
inlines.Insert(startIndex, link);
|
||||
|
||||
link.Destination = reference.Destination;
|
||||
link.Title = reference.Title;
|
||||
newIndex = index + 1;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private int FindOpenBracketIndex(char ch)
|
||||
{
|
||||
for (var index = delimiters.Count - 1; index >= 0; index--)
|
||||
{
|
||||
if (delimiters[index].Char == ch)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void ParseEmphasisAndStrong(int index = -1)
|
||||
{
|
||||
var closerIndex = 0;
|
||||
|
||||
while ((closerIndex = FindCloserIndex()) > 0)
|
||||
{
|
||||
var openerIndex = FindOpenerIndex(closerIndex, index);
|
||||
|
||||
if (openerIndex >= 0)
|
||||
{
|
||||
var closer = delimiters[closerIndex];
|
||||
var opener = delimiters[openerIndex];
|
||||
var startIndex = inlines.FindIndex(opener.Node.Equals);
|
||||
var endIndex = inlines.FindIndex(closer.Node.Equals);
|
||||
|
||||
if (startIndex >= 0 && endIndex >= 0)
|
||||
{
|
||||
var innerInlines = inlines.GetRange(startIndex + 1, endIndex - startIndex - 1);
|
||||
|
||||
var charsToConsume = closer.Length == opener.Length && closer.Length > 1 ? 2 : 1;
|
||||
|
||||
InlineContainer parent = charsToConsume == 1 ? new Emphasis() : new Strong();
|
||||
|
||||
foreach (var child in innerInlines)
|
||||
{
|
||||
parent.Add(child);
|
||||
}
|
||||
|
||||
opener.Length -= charsToConsume;
|
||||
|
||||
if (opener.Length > 0)
|
||||
{
|
||||
opener.Node.Value = opener.Node.Value[..^charsToConsume];
|
||||
startIndex += charsToConsume;
|
||||
}
|
||||
|
||||
closer.Length -= charsToConsume;
|
||||
|
||||
if (closer.Length > 0)
|
||||
{
|
||||
closer.Node.Value = closer.Node.Value[..^charsToConsume];
|
||||
endIndex -= charsToConsume;
|
||||
}
|
||||
|
||||
inlines.RemoveRange(startIndex, endIndex - startIndex + 1);
|
||||
|
||||
inlines.Insert(startIndex, parent);
|
||||
}
|
||||
|
||||
delimiters.RemoveAt(closerIndex);
|
||||
delimiters.RemoveAt(openerIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int FindCloserIndex()
|
||||
{
|
||||
for (var index = 1; index < delimiters.Count; index++)
|
||||
{
|
||||
var delimiter = delimiters[index];
|
||||
|
||||
if (delimiter.CanClose && (delimiter.Char is Asterisk or Underscore))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int FindOpenerIndex(int startIndex, int endIndex)
|
||||
{
|
||||
var closer = delimiters[startIndex];
|
||||
|
||||
for (var index = startIndex - 1; index > endIndex; index--)
|
||||
{
|
||||
var delimiter = delimiters[index];
|
||||
|
||||
if (delimiter.CanOpen && delimiter.Char == closer.Char)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
22
Radzen.Blazor/Markdown/InlineVisitor.cs
Normal file
22
Radzen.Blazor/Markdown/InlineVisitor.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
class InlineVisitor(Dictionary<string, LinkReference> references) : NodeVisitorBase
|
||||
{
|
||||
public override void VisitHeading(Heading heading) => ParseChildren(heading, references);
|
||||
|
||||
private static void ParseChildren(IBlockInlineContainer node, Dictionary<string, LinkReference> references)
|
||||
{
|
||||
var inlines = InlineParser.Parse(node.Value, references);
|
||||
|
||||
foreach (var inline in inlines)
|
||||
{
|
||||
node.Add(inline);
|
||||
}
|
||||
}
|
||||
|
||||
public override void VisitParagraph(Paragraph paragraph) => ParseChildren(paragraph, references);
|
||||
|
||||
public override void VisitTableCell(TableCell cell) => ParseChildren(cell, references);
|
||||
}
|
||||
42
Radzen.Blazor/Markdown/Leaf.cs
Normal file
42
Radzen.Blazor/Markdown/Leaf.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for markdown leaf block nodes.
|
||||
/// </summary>
|
||||
public abstract class Leaf : Block, IBlockInlineContainer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the value of the leaf node.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
private readonly List<Inline> children = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the children of the leaf node.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Inline> Children => children;
|
||||
|
||||
/// <summary>
|
||||
/// Appends a child to the leaf node.
|
||||
/// </summary>
|
||||
public void Add(Inline node)
|
||||
{
|
||||
children.Add(node);
|
||||
}
|
||||
internal void AddLine(BlockParser blockParser)
|
||||
{
|
||||
if (blockParser.PartiallyConsumedTab)
|
||||
{
|
||||
blockParser.Offset += 1;
|
||||
|
||||
var charsToTab = 4 - (blockParser.Column % 4);
|
||||
|
||||
Value += new string(' ', charsToTab);
|
||||
}
|
||||
|
||||
Value += blockParser.CurrentLine[blockParser.Offset..] + "\n";
|
||||
}
|
||||
}
|
||||
13
Radzen.Blazor/Markdown/LineBreak.cs
Normal file
13
Radzen.Blazor/Markdown/LineBreak.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a line break node. Line breaks are usually empty lines and are used to separate paragraphs.
|
||||
/// </summary>
|
||||
public class LineBreak : Inline
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitLineBreak(this);
|
||||
}
|
||||
}
|
||||
23
Radzen.Blazor/Markdown/Link.cs
Normal file
23
Radzen.Blazor/Markdown/Link.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a link element: <c>[Link text](/path/to/page "Optional title")</c>
|
||||
/// </summary>
|
||||
public class Link : InlineContainer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the destination (URL) of the link.
|
||||
/// </summary>
|
||||
public string Destination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the link title.
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitLink(this);
|
||||
}
|
||||
}
|
||||
7
Radzen.Blazor/Markdown/LinkReference.cs
Normal file
7
Radzen.Blazor/Markdown/LinkReference.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
class LinkReference
|
||||
{
|
||||
public string Destination { get; set; }
|
||||
public string Title { get; set; }
|
||||
}
|
||||
46
Radzen.Blazor/Markdown/LinkReferenceParser.cs
Normal file
46
Radzen.Blazor/Markdown/LinkReferenceParser.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
class LinkReferenceParser(BlockParser parser) : NodeVisitorBase
|
||||
{
|
||||
private readonly List<Block> emptyNodes = [];
|
||||
|
||||
public override void VisitParagraph(Paragraph paragraph)
|
||||
{
|
||||
var hasReferenceDefs = false;
|
||||
// Try parsing the beginning as link reference definitions;
|
||||
// Note that link reference definitions must be the beginning of a
|
||||
// paragraph node since link reference definitions cannot interrupt
|
||||
// paragraphs.
|
||||
while (paragraph.Value.Peek() == '[' && parser.TryParseLinkReference(paragraph.Value, out var position))
|
||||
{
|
||||
var removedText = paragraph.Value[..position];
|
||||
|
||||
paragraph.Value = paragraph.Value[position..];
|
||||
hasReferenceDefs = true;
|
||||
|
||||
var lines = removedText.Split('\n');
|
||||
|
||||
// -1 for final newline.
|
||||
paragraph.Range.Start.Line += lines.Length - 1;
|
||||
}
|
||||
|
||||
if (hasReferenceDefs && string.IsNullOrWhiteSpace(paragraph.Value))
|
||||
{
|
||||
emptyNodes.Add(paragraph);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Parse(BlockParser parser, Document document)
|
||||
{
|
||||
var visitor = new LinkReferenceParser(parser);
|
||||
|
||||
document.Accept(visitor);
|
||||
|
||||
foreach (var node in visitor.emptyNodes)
|
||||
{
|
||||
node.Remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
74
Radzen.Blazor/Markdown/ListBase.cs
Normal file
74
Radzen.Blazor/Markdown/ListBase.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for list elements (ordered and unordered).
|
||||
/// </summary>
|
||||
public abstract class List : BlockContainer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list marker.
|
||||
/// </summary>
|
||||
public char Marker { get; set; }
|
||||
|
||||
internal int MarkerOffset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the list is tight. Tight lists have no space between their items.
|
||||
/// </summary>
|
||||
public bool Tight { get; set; } = true;
|
||||
internal int Padding { get; set; }
|
||||
internal string Delimiter { get; set; }
|
||||
|
||||
internal override BlockMatch Matches(BlockParser parser)
|
||||
{
|
||||
return BlockMatch.Match;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanContain(Block node)
|
||||
{
|
||||
return node is ListItem;
|
||||
}
|
||||
|
||||
private static bool EndsWithBlankLine(Block block)
|
||||
{
|
||||
return block.Next != null && block.Range.End.Line != block.Next.Range.Start.Line - 1;
|
||||
}
|
||||
|
||||
internal override void Close(BlockParser parser)
|
||||
{
|
||||
base.Close(parser);
|
||||
|
||||
var item = FirstChild;
|
||||
|
||||
while (item != null)
|
||||
{
|
||||
// check for non-final list item ending with blank line:
|
||||
if (item.Next != null && EndsWithBlankLine(item))
|
||||
{
|
||||
Tight = false;
|
||||
break;
|
||||
}
|
||||
// recurse into children of list item, to see if there are
|
||||
// spaces between any of them:
|
||||
var subitem = item.FirstChild;
|
||||
|
||||
while (subitem != null)
|
||||
{
|
||||
if (subitem.Next != null && EndsWithBlankLine(subitem))
|
||||
{
|
||||
Tight = false;
|
||||
break;
|
||||
}
|
||||
subitem = subitem.Next;
|
||||
}
|
||||
|
||||
item = item.Next;
|
||||
}
|
||||
|
||||
if (LastChild != null)
|
||||
{
|
||||
Range.End = LastChild.Range.End;
|
||||
}
|
||||
}
|
||||
}
|
||||
199
Radzen.Blazor/Markdown/ListItem.cs
Normal file
199
Radzen.Blazor/Markdown/ListItem.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// Represents a list item node.
|
||||
/// </summary>
|
||||
public class ListItem : BlockContainer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitListItem(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanContain(Block node)
|
||||
{
|
||||
return node is not ListItem;
|
||||
}
|
||||
|
||||
internal override BlockMatch Matches(BlockParser parser)
|
||||
{
|
||||
if (parser.Blank)
|
||||
{
|
||||
if (Children.Count == 0)
|
||||
{
|
||||
// Blank line after empty list item
|
||||
return BlockMatch.Skip;
|
||||
}
|
||||
else
|
||||
{
|
||||
parser.AdvanceNextNonSpace();
|
||||
}
|
||||
}
|
||||
else if (parser.Indent >= data.MarkerOffset + data.Padding)
|
||||
{
|
||||
parser.AdvanceOffset(data.MarkerOffset + data.Padding, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
return BlockMatch.Skip;
|
||||
}
|
||||
|
||||
return BlockMatch.Match;
|
||||
}
|
||||
|
||||
internal override void Close(BlockParser parser)
|
||||
{
|
||||
base.Close(parser);
|
||||
|
||||
if (LastChild != null)
|
||||
{
|
||||
Range.End = LastChild.Range.End;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Empty list item
|
||||
Range.End.Line = Range.Start.Line;
|
||||
|
||||
if (Parent is List list)
|
||||
{
|
||||
Range.End.Column = list.MarkerOffset + list.Padding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static BlockStart Start(BlockParser parser, Block container)
|
||||
{
|
||||
if ((!parser.Indented || container is List) && TryParseListMarker(parser, container, out var data))
|
||||
{
|
||||
parser.CloseUnmatchedBlocks();
|
||||
|
||||
var list = container as List ?? (container.Parent is ListItem item ? item.data : null);
|
||||
|
||||
// add the list if needed
|
||||
if (parser.Tip is not List || !ListsMatch(list, data))
|
||||
{
|
||||
parser.AddChild(data, parser.NextNonSpace);
|
||||
}
|
||||
|
||||
var node = parser.AddChild<ListItem>(parser.NextNonSpace);
|
||||
node.data = data;
|
||||
|
||||
return BlockStart.Container;
|
||||
}
|
||||
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
|
||||
private List data = null!;
|
||||
|
||||
private static readonly Regex UnorderedMarkerRegex = new(@"^[*+-]");
|
||||
|
||||
private static readonly Regex OrderedMarkerRegex = new(@"^(\d{1,9})([.)])");
|
||||
|
||||
private static bool TryParseListMarker(BlockParser parser, Block container, out List data)
|
||||
{
|
||||
data = null!;
|
||||
|
||||
if (parser.Indent >= 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rest = parser.CurrentLine[parser.NextNonSpace..];
|
||||
|
||||
var match = UnorderedMarkerRegex.Match(rest);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
data = new UnorderedList
|
||||
{
|
||||
Marker = match.Value[0],
|
||||
MarkerOffset = parser.Indent
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
match = OrderedMarkerRegex.Match(rest);
|
||||
|
||||
if (match.Success && (container is not Paragraph || match.Groups[1].Value == "1"))
|
||||
{
|
||||
var list = new OrderedList
|
||||
{
|
||||
MarkerOffset = parser.Indent,
|
||||
Start = int.Parse(match.Groups[1].Value),
|
||||
Delimiter = match.Groups[2].Value
|
||||
};
|
||||
data = list;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// make sure we have spaces after
|
||||
var ch = parser.PeekNonSpace(match.Length);
|
||||
|
||||
if (ch != default && !ch.IsSpaceOrTab())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// if it interrupts paragraph, make sure first line isn't blank
|
||||
if (container is Paragraph && string.IsNullOrWhiteSpace(parser.CurrentLine[(parser.NextNonSpace + match.Length)..]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// we've got a match! advance offset and calculate padding
|
||||
parser.AdvanceNextNonSpace(); // to start of marker
|
||||
parser.AdvanceOffset(match.Length, true); // to end of marker
|
||||
|
||||
var startColumn = parser.Column;
|
||||
var startOffset = parser.Offset;
|
||||
|
||||
do
|
||||
{
|
||||
parser.AdvanceOffset(1, true);
|
||||
ch = parser.Peek();
|
||||
} while (parser.Column - startColumn < 5 && ch.IsSpaceOrTab());
|
||||
|
||||
var blank = parser.Peek() == default;
|
||||
|
||||
var spacesAfterMarker = parser.Column - startColumn;
|
||||
|
||||
if (spacesAfterMarker >= 5 || spacesAfterMarker < 1 || blank)
|
||||
{
|
||||
data.Padding = match.Length + 1;
|
||||
parser.Column = startColumn;
|
||||
parser.Offset = startOffset;
|
||||
|
||||
if (parser.Peek().IsSpaceOrTab())
|
||||
{
|
||||
parser.AdvanceOffset(1, true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
data.Padding = match.Length + spacesAfterMarker;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ListsMatch(List? x, List y)
|
||||
{
|
||||
if (x == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return x.GetType() == y.GetType() && x.Marker == y.Marker && x.Delimiter == y.Delimiter;
|
||||
}
|
||||
}
|
||||
17
Radzen.Blazor/Markdown/MarkdownParser.cs
Normal file
17
Radzen.Blazor/Markdown/MarkdownParser.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Markdown document.
|
||||
/// </summary>
|
||||
public static class MarkdownParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a string containing Markdown into a document.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The Markdown content to parse.</param>
|
||||
/// <returns>The parsed document.</returns>
|
||||
public static Document Parse(string markdown)
|
||||
{
|
||||
return BlockParser.Parse(markdown);
|
||||
}
|
||||
}
|
||||
159
Radzen.Blazor/Markdown/NodeVisitorBase.cs
Normal file
159
Radzen.Blazor/Markdown/NodeVisitorBase.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for visitors that traverse a Markdown document.
|
||||
/// </summary>
|
||||
public abstract class NodeVisitorBase : INodeVisitor
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Visits a block quote by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitBlockQuote(BlockQuote blockQuote) => VisitChildren(blockQuote.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a document by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitDocument(Document document) => VisitChildren(document.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a heading by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitHeading(Heading heading) => VisitChildren(heading.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a list item by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitListItem(ListItem listItem) => VisitChildren(listItem.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an ordered list by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitOrderedList(OrderedList orderedList) => VisitChildren(orderedList.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a paragraph by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitParagraph(Paragraph paragraph) => VisitChildren(paragraph.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a thematic break.
|
||||
/// </summary>
|
||||
public virtual void VisitThematicBreak(ThematicBreak thematicBreak)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a text node.
|
||||
/// </summary>
|
||||
public virtual void VisitText(Text text)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a code node.
|
||||
/// </summary>
|
||||
public virtual void VisitCode(Code code)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits an HTML block.
|
||||
/// </summary>
|
||||
public virtual void VisitHtmlInline(HtmlInline html)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a line break.
|
||||
/// </summary>
|
||||
public virtual void VisitLineBreak(LineBreak lineBreak)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a soft line break.
|
||||
/// </summary>
|
||||
public virtual void VisitSoftLineBreak(SoftLineBreak softLineBreak)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits an ordered list by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitUnorderedList(UnorderedList unorderedList) => VisitChildren(unorderedList.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an emphasis by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitEmphasis(Emphasis emphasis) => VisitChildren(emphasis.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a strong by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitStrong(Strong strong) => VisitChildren(strong.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a link by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitLink(Link link) => VisitChildren(link.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits an image by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitImage(Image image) => VisitChildren(image.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a code block.
|
||||
/// </summary>
|
||||
public virtual void VisitIndentedCodeBlock(IndentedCodeBlock codeBlock)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a fenced code block.
|
||||
/// </summary>
|
||||
public virtual void VisitFencedCodeBlock(FencedCodeBlock fencedCodeBlock)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits an HTML block.
|
||||
/// </summary>
|
||||
public virtual void VisitHtmlBlock(HtmlBlock htmlBlock)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a table.
|
||||
/// </summary>
|
||||
public virtual void VisitTable(Table table) => VisitChildren(table.Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a table header row by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitTableHeaderRow(TableHeaderRow header) => VisitChildren(header.Cells);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a table row by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitTableRow(TableRow row) => VisitChildren(row.Cells);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a table cell by visiting its children.
|
||||
/// </summary>
|
||||
public virtual void VisitTableCell(TableCell cell) => VisitChildren(cell.Children);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a collection of nodes.
|
||||
/// </summary>
|
||||
protected void VisitChildren(IEnumerable<INode> children)
|
||||
{
|
||||
foreach (var node in children)
|
||||
{
|
||||
node.Accept(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Radzen.Blazor/Markdown/OrderedList.cs
Normal file
18
Radzen.Blazor/Markdown/OrderedList.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an ordered list: <c>1. item</c>.
|
||||
/// </summary>
|
||||
public class OrderedList : List
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitOrderedList(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the start number of the ordered list.
|
||||
/// </summary>
|
||||
public int Start { get; set; }
|
||||
}
|
||||
19
Radzen.Blazor/Markdown/Paragraph.cs
Normal file
19
Radzen.Blazor/Markdown/Paragraph.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a paragraph node.
|
||||
/// </summary>
|
||||
public class Paragraph : Leaf
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitParagraph(this);
|
||||
}
|
||||
|
||||
internal override BlockMatch Matches(BlockParser parser)
|
||||
{
|
||||
return parser.Blank ? BlockMatch.Skip : BlockMatch.Match;
|
||||
}
|
||||
}
|
||||
50
Radzen.Blazor/Markdown/SetExtHeading.cs
Normal file
50
Radzen.Blazor/Markdown/SetExtHeading.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a setext heading node. Setext headings are headings that are underlined with equal signs for level 1 headings and dashes for level 2 headings.
|
||||
/// </summary>
|
||||
public class SetExtHeading : Heading
|
||||
{
|
||||
private static readonly Regex HeadingRegex = new (@"^(?:=+|-+)[ \t]*$");
|
||||
|
||||
internal static BlockStart Start(BlockParser parser, Block block)
|
||||
{
|
||||
if (parser.Indented || block is not Paragraph paragraph)
|
||||
{
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
|
||||
var line = parser.CurrentLine[parser.NextNonSpace..];
|
||||
|
||||
var match = HeadingRegex.Match(line);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
parser.CloseUnmatchedBlocks();
|
||||
|
||||
// resolve reference links
|
||||
while (paragraph.Value.Peek() == '[' && parser.TryParseLinkReference(paragraph.Value, out var position))
|
||||
{
|
||||
paragraph.Value = paragraph.Value[position..];
|
||||
}
|
||||
|
||||
if (paragraph.Value.Length > 0)
|
||||
{
|
||||
var heading = new SetExtHeading
|
||||
{
|
||||
Level = match.Value[0] == '=' ? 1 : 2,
|
||||
Value = paragraph.Value
|
||||
};
|
||||
paragraph.Parent.Replace(paragraph, heading);
|
||||
parser.Tip = heading;
|
||||
parser.AdvanceOffset(parser.CurrentLine.Length - parser.Offset, false);
|
||||
|
||||
return BlockStart.Leaf;
|
||||
}
|
||||
}
|
||||
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
}
|
||||
13
Radzen.Blazor/Markdown/SoftLineBreak.cs
Normal file
13
Radzen.Blazor/Markdown/SoftLineBreak.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a soft line break node. Soft line breaks are usually used to separate lines in a paragraph.
|
||||
/// </summary>
|
||||
public class SoftLineBreak : Inline
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitSoftLineBreak(this);
|
||||
}
|
||||
}
|
||||
9
Radzen.Blazor/Markdown/StringExtensions.cs
Normal file
9
Radzen.Blazor/Markdown/StringExtensions.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
static class StringExtensions
|
||||
{
|
||||
public static char Peek(this string line, int offset = 0)
|
||||
{
|
||||
return offset < line.Length ? line[offset] : default;
|
||||
}
|
||||
}
|
||||
13
Radzen.Blazor/Markdown/Strong.cs
Normal file
13
Radzen.Blazor/Markdown/Strong.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a strong node: <c>**strong**</c>.
|
||||
/// </summary>
|
||||
public class Strong : InlineContainer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitStrong(this);
|
||||
}
|
||||
}
|
||||
348
Radzen.Blazor/Markdown/Table.cs
Normal file
348
Radzen.Blazor/Markdown/Table.cs
Normal file
@@ -0,0 +1,348 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Radzen.Blazor.Markdown;
|
||||
|
||||
/// <summary>
|
||||
/// The alignment of a table cell.
|
||||
/// </summary>
|
||||
public enum TableCellAlignment
|
||||
{
|
||||
/// <summary>
|
||||
/// No alignment specified. Default alignment is left.
|
||||
/// </summary>
|
||||
None,
|
||||
/// <summary>
|
||||
/// Left alignment.
|
||||
/// </summary>
|
||||
Left,
|
||||
/// <summary>
|
||||
/// Center alignment.
|
||||
/// </summary>
|
||||
Center,
|
||||
/// <summary>
|
||||
/// Right alignment.
|
||||
/// </summary>
|
||||
Right
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a table in a Markdown document.
|
||||
/// </summary>
|
||||
public class Table : Leaf
|
||||
{
|
||||
private readonly List<TableRow> rows = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rows of the table.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TableRow> Rows => rows;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitTable(this);
|
||||
}
|
||||
|
||||
private static readonly Regex DelimiterRegex = new(@"^\s*(\|?\s*:?-{1,}:?\s*)+(\|+\s*:?-{1,}:?\s*)*\|?\s*$");
|
||||
|
||||
internal override void Close(BlockParser parser)
|
||||
{
|
||||
base.Close(parser);
|
||||
|
||||
var header = rows[0];
|
||||
var headerCells = header.Cells;
|
||||
|
||||
var dataLines = Value.Split('\n');
|
||||
|
||||
foreach (var line in dataLines)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
var row = new TableRow();
|
||||
var cells = ParseRow(line);
|
||||
|
||||
// Trim excess cells
|
||||
var count = Math.Min(cells.Count, headerCells.Count);
|
||||
|
||||
for (int cellIndex = 0; cellIndex < count; cellIndex++)
|
||||
{
|
||||
var alignment = cellIndex < headerCells.Count ? headerCells[cellIndex].Alignment : TableCellAlignment.None;
|
||||
|
||||
row.Add(cells[cellIndex], alignment);
|
||||
}
|
||||
|
||||
for (int missingCellIndex = 0; missingCellIndex < header.Cells.Count - cells.Count; missingCellIndex++)
|
||||
{
|
||||
row.Add("", TableCellAlignment.None);
|
||||
}
|
||||
|
||||
rows.Add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ParseRow(string line)
|
||||
{
|
||||
// Remove leading and trailing pipes if present
|
||||
line = line.Trim();
|
||||
|
||||
if (line.StartsWith('|'))
|
||||
{
|
||||
line = line[1..];
|
||||
}
|
||||
if (line.EndsWith('|'))
|
||||
{
|
||||
line = line[..^1];
|
||||
}
|
||||
|
||||
// Split by pipe character, but not by escaped pipes
|
||||
var cells = new List<string>();
|
||||
var currentCell = "";
|
||||
var escaped = false;
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
|
||||
if (escaped)
|
||||
{
|
||||
// Add the escaped character (including escaped pipes)
|
||||
if (c == '|')
|
||||
{
|
||||
currentCell += '|'; // Replace \| with |
|
||||
}
|
||||
else
|
||||
{
|
||||
currentCell += $"\\{c}"; // Keep the escape character for other escaped chars
|
||||
}
|
||||
escaped = false;
|
||||
}
|
||||
else if (c == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
}
|
||||
else if (c == '|')
|
||||
{
|
||||
// End of cell
|
||||
cells.Add(currentCell.Trim());
|
||||
currentCell = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
currentCell += c;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last cell
|
||||
if (!string.IsNullOrEmpty(currentCell) || cells.Count > 0)
|
||||
{
|
||||
cells.Add(currentCell.Trim());
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
internal override BlockMatch Matches(BlockParser parser)
|
||||
{
|
||||
return parser.Blank ? BlockMatch.Skip : BlockMatch.Match;
|
||||
}
|
||||
|
||||
internal static BlockStart Start(BlockParser parser, Block block)
|
||||
{
|
||||
if (parser.Indented || block is not Paragraph paragraph)
|
||||
{
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
|
||||
var line = parser.CurrentLine[parser.NextNonSpace..];
|
||||
|
||||
// Check if the line contains a pipe character to be more specific about table delimiters
|
||||
// This helps avoid misinterpreting heading delimiters as table delimiters
|
||||
if (!line.Contains('|') && !line.Contains(':'))
|
||||
{
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
|
||||
var match = DelimiterRegex.Match(line);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
// Parse the delimiter row to determine alignments
|
||||
var delimiterRow = line.Trim();
|
||||
|
||||
// Parse the header row from the paragraph text
|
||||
var headerLine = paragraph.Value.Trim();
|
||||
|
||||
// Parse header cells and delimiter cells
|
||||
var headerCells = ParseRow(headerLine);
|
||||
|
||||
// Parse delimiter cells to determine alignments
|
||||
var cleanDelimiterRow = delimiterRow;
|
||||
if (cleanDelimiterRow.StartsWith('|'))
|
||||
{
|
||||
cleanDelimiterRow = cleanDelimiterRow[1..];
|
||||
}
|
||||
if (cleanDelimiterRow.EndsWith('|'))
|
||||
{
|
||||
cleanDelimiterRow = cleanDelimiterRow[..^1];
|
||||
}
|
||||
|
||||
// Split by pipe character
|
||||
var delimiters = cleanDelimiterRow.Split('|');
|
||||
|
||||
// If the number of header cells and delimiter cells don't match, don't create a table
|
||||
if (headerCells.Count != delimiters.Length)
|
||||
{
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
|
||||
// Initialize alignments list
|
||||
var alignments = new List<TableCellAlignment>(headerCells.Count);
|
||||
for (int i = 0; i < headerCells.Count; i++)
|
||||
{
|
||||
alignments.Add(TableCellAlignment.None);
|
||||
}
|
||||
|
||||
// Determine alignments from delimiters
|
||||
for (var i = 0; i < delimiters.Length && i < alignments.Count; i++)
|
||||
{
|
||||
var trimmed = delimiters[i].Trim();
|
||||
|
||||
if (trimmed.StartsWith(':') && trimmed.EndsWith(':'))
|
||||
{
|
||||
alignments[i] = TableCellAlignment.Center;
|
||||
}
|
||||
else if (trimmed.EndsWith(':'))
|
||||
{
|
||||
alignments[i] = TableCellAlignment.Right;
|
||||
}
|
||||
else if (trimmed.StartsWith(':'))
|
||||
{
|
||||
alignments[i] = TableCellAlignment.Left;
|
||||
}
|
||||
}
|
||||
|
||||
parser.CloseUnmatchedBlocks();
|
||||
|
||||
// resolve reference links
|
||||
while (paragraph.Value.Peek() == '[' && parser.TryParseLinkReference(paragraph.Value, out var position))
|
||||
{
|
||||
paragraph.Value = paragraph.Value[position..];
|
||||
}
|
||||
|
||||
if (paragraph.Value.Length > 0)
|
||||
{
|
||||
var table = new Table();
|
||||
|
||||
// Create header row
|
||||
|
||||
var header = new TableHeaderRow();
|
||||
table.rows.Add(header);
|
||||
|
||||
// Add header cells with alignments
|
||||
for (int cellindex = 0; cellindex < headerCells.Count; cellindex++)
|
||||
{
|
||||
var alignment = cellindex < alignments.Count ? alignments[cellindex] : TableCellAlignment.None;
|
||||
header.Add(headerCells[cellindex], alignment);
|
||||
}
|
||||
|
||||
paragraph.Parent.Replace(paragraph, table);
|
||||
parser.Tip = table;
|
||||
parser.AdvanceOffset(parser.CurrentLine.Length - parser.Offset, false);
|
||||
|
||||
return BlockStart.Leaf;
|
||||
}
|
||||
}
|
||||
|
||||
return BlockStart.Skip;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a table header row in a Markdown table.
|
||||
/// </summary>
|
||||
public class TableHeaderRow : TableRow
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitTableHeaderRow(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a table row in a Markdown table.
|
||||
/// </summary>
|
||||
public class TableRow : INode
|
||||
{
|
||||
private readonly List<TableCell> children = [];
|
||||
|
||||
/// <summary>
|
||||
/// /// Gets the cells of the table row.
|
||||
/// </summary>
|
||||
|
||||
public IReadOnlyList<TableCell> Cells => children;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a cell to the table row.
|
||||
/// </summary>
|
||||
public void Add(string value, TableCellAlignment alignment = TableCellAlignment.None)
|
||||
{
|
||||
var cell = new TableCell(value, alignment);
|
||||
children.Add(cell);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitTableRow(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a table cell in a Markdown table.
|
||||
/// </summary>
|
||||
public class TableCell : INode, IBlockInlineContainer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the alignment of the table cell.
|
||||
/// </summary>
|
||||
public TableCellAlignment Alignment { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TableCell"/> class.
|
||||
/// </summary>
|
||||
public TableCell(string value, TableCellAlignment alignment = TableCellAlignment.None)
|
||||
{
|
||||
Value = value;
|
||||
Alignment = alignment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the inline content of the cell
|
||||
/// </summary>
|
||||
public string Value { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Accept(INodeVisitor visitor)
|
||||
{
|
||||
visitor.VisitTableCell(this);
|
||||
}
|
||||
|
||||
private readonly List<Inline> children = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the children of the table cell.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Inline> Children => children;
|
||||
|
||||
/// <summary>
|
||||
/// Appends a child to table cell.
|
||||
/// </summary>
|
||||
public void Add(Inline node)
|
||||
{
|
||||
children.Add(node);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user