Rust library to generate static websites
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: markdown shortcodes (#30)

* feat: implementation

* fix: some things

* fix: adjust logic

* feat: rework some things

* chore: changeset

authored by

Erika and committed by
GitHub
39db004b e05446fd

+1998 -27
+51
.sampo/changesets/gallant-knight-louhi.md
··· 1 + --- 2 + packages: 3 + - maudit 4 + release: minor 5 + --- 6 + 7 + Added support for shortcodes in Markdown. Shortcodes allows you to substitute custom content in your Markdown files. This feature is useful for embedding dynamic content or reusable components within your Markdown documents. 8 + 9 + For instance, you might define a shortcode for embedding YouTube videos using only the video ID, or for inserting custom alerts or notes. 10 + 11 + ```markdown 12 + {{ youtube id="FbJ63spk48s" }} 13 + ``` 14 + 15 + Would render to: 16 + 17 + ```html 18 + <iframe 19 + width="560" 20 + height="315" 21 + src="https://www.youtube.com/embed/FbJ63spk48s?si=hUGRndTWIThVY-72" 22 + title="YouTube video player" 23 + frameborder="0" 24 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 25 + referrerpolicy="strict-origin-when-cross-origin" 26 + allowfullscreen 27 + ></iframe> 28 + ``` 29 + 30 + To define and register shortcodes, pass a MarkdownShortcodes instance to the MarkdownOptions when rendering Markdown content. 31 + 32 + ```rust 33 + let mut shortcodes = MarkdownShortcodes::new(); 34 + 35 + shortcodes.register("youtube", |args, _ctx| { 36 + let id: String = args.get_required("id"); 37 + format!( 38 + r#"<iframe width="560" height="315" src="https://www.youtube.com/embed/{}" frameborder="0" allowfullscreen></iframe>"#, 39 + id 40 + ) 41 + }); 42 + 43 + MarkdownOptions { 44 + shortcodes, 45 + ..Default::default() 46 + } 47 + 48 + // Then pass options to, i.e. glob_markdown in a content source 49 + ``` 50 + 51 + Note that shortcodes are expanded before Markdown is rendered, so you can use shortcodes anywhere in your Markdown content, for instance in your frontmatter. Additionally, shortcodes may expand to Markdown content, which will then be rendered as part of the overall Markdown rendering process.
+1 -1
.sampo/config.toml
··· 1 1 [packages] 2 - linked_dependencies = [["maudit", "maudit-macros"]] 2 + linked = [["maudit", "maudit-macros"]]
+11 -17
Cargo.lock
··· 2619 2619 ] 2620 2620 2621 2621 [[package]] 2622 - name = "libyml" 2623 - version = "0.0.5" 2624 - source = "registry+https://github.com/rust-lang/crates.io-index" 2625 - checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" 2626 - dependencies = [ 2627 - "anyhow", 2628 - "version_check", 2629 - ] 2630 - 2631 - [[package]] 2632 2622 name = "linked-hash-map" 2633 2623 version = "0.5.6" 2634 2624 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2762 2752 "rayon", 2763 2753 "rustc-hash", 2764 2754 "serde", 2765 - "serde_yml", 2755 + "serde_yaml", 2766 2756 "slug", 2767 2757 "syntect", 2768 2758 "thiserror 2.0.16", ··· 4704 4694 ] 4705 4695 4706 4696 [[package]] 4707 - name = "serde_yml" 4708 - version = "0.0.12" 4697 + name = "serde_yaml" 4698 + version = "0.9.34+deprecated" 4709 4699 source = "registry+https://github.com/rust-lang/crates.io-index" 4710 - checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" 4700 + checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 4711 4701 dependencies = [ 4712 4702 "indexmap", 4713 4703 "itoa", 4714 - "libyml", 4715 - "memchr", 4716 4704 "ryu", 4717 4705 "serde", 4718 - "version_check", 4706 + "unsafe-libyaml", 4719 4707 ] 4720 4708 4721 4709 [[package]] ··· 5536 5524 version = "0.2.6" 5537 5525 source = "registry+https://github.com/rust-lang/crates.io-index" 5538 5526 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 5527 + 5528 + [[package]] 5529 + name = "unsafe-libyaml" 5530 + version = "0.2.11" 5531 + source = "registry+https://github.com/rust-lang/crates.io-index" 5532 + checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 5539 5533 5540 5534 [[package]] 5541 5535 name = "untrusted"
+2 -2
crates/maudit/Cargo.toml
··· 23 23 # TODO: Allow making those optional 24 24 rolldown = { package = "brk_rolldown", version = "0.1.4" } 25 25 serde = { workspace = true } 26 - serde_yml = "0.0.12" 26 + serde_yaml = "0.9.34" 27 27 pulldown-cmark = "0.12.2" 28 28 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 29 29 glob = "0.3.1" ··· 47 47 blake3 = "1.8.2" 48 48 oxc_sourcemap = "4.1.0" 49 49 rayon = "1.11.0" 50 - quanta = "0.12.6" 50 + quanta = "0.12.6"
+446 -5
crates/maudit/src/content/markdown.rs
··· 6 6 use serde::de::DeserializeOwned; 7 7 8 8 pub mod components; 9 + pub mod shortcodes; 9 10 10 11 use components::{LinkType, ListType, MarkdownComponents, TableAlignment}; 11 12 12 - use crate::{assets::Asset, page::RouteContext}; 13 + use crate::{ 14 + assets::Asset, 15 + content::shortcodes::{MarkdownShortcodes, preprocess_shortcodes}, 16 + page::RouteContext, 17 + }; 13 18 14 19 use super::{ContentEntry, highlight::CodeBlock, slugger}; 20 + 21 + #[cfg(test)] 22 + mod shortcodes_tests; 15 23 16 24 /// Represents a Markdown heading. 17 25 /// ··· 138 146 #[derive(Default)] 139 147 pub struct MarkdownOptions { 140 148 pub components: MarkdownComponents, 149 + pub shortcodes: MarkdownShortcodes, 141 150 } 142 151 143 152 impl MarkdownOptions { ··· 145 154 Self::default() 146 155 } 147 156 148 - pub fn with_components(components: MarkdownComponents) -> Self { 149 - Self { components } 157 + pub fn with_components(components: MarkdownComponents, shortcodes: MarkdownShortcodes) -> Self { 158 + Self { 159 + components, 160 + shortcodes, 161 + } 150 162 } 151 163 } 152 164 ··· 296 308 path: Option<&Path>, 297 309 mut route_ctx: Option<&mut RouteContext>, 298 310 ) -> String { 311 + let content = if let Some(shortcodes) = options.map(|o| &o.shortcodes) 312 + && !shortcodes.is_empty() 313 + { 314 + preprocess_shortcodes(content, shortcodes, route_ctx.as_deref_mut()) 315 + .unwrap_or_else(|e| panic!("Failed to preprocess shortcodes: {}", e)) 316 + } else { 317 + content.to_string() 318 + }; 319 + 299 320 let mut slugger = slugger::Slugger::new(); 300 321 let mut html_output = String::new(); 301 322 let parser_options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS ··· 310 331 let mut code_block_content = String::new(); 311 332 let mut in_frontmatter = false; 312 333 let mut in_image = false; 313 - let mut events = Parser::new_ext(content, parser_options).collect::<Vec<Event>>(); 334 + let mut events = Parser::new_ext(&content, parser_options).collect::<Vec<Event>>(); 314 335 315 336 let options_with_components = options 316 337 .as_ref() ··· 788 809 789 810 // TODO: Prettier errors for serialization errors (e.g. missing fields) 790 811 // TODO: Support TOML frontmatters 791 - let mut parsed = serde_yml::from_str::<T>(&frontmatter) 812 + let mut parsed = serde_yaml::from_str::<T>(&frontmatter) 792 813 .unwrap_or_else(|e| panic!("Failed to parse YAML frontmatter: {}, {}", e, frontmatter)); 793 814 794 815 let headings_internal = find_headings(&content_events); ··· 854 875 fn test_rendering_with_empty_components() { 855 876 let options = MarkdownOptions { 856 877 components: MarkdownComponents::new(), 878 + ..Default::default() 857 879 }; 858 880 let markdown = r#"# Hello, world!"#; 859 881 ··· 878 900 // Render with options but no custom heading component 879 901 let options_no_heading = MarkdownOptions { 880 902 components: MarkdownComponents::new(), 903 + ..Default::default() 881 904 }; 882 905 let html_with_empty_options = 883 906 render_markdown(markdown, Some(&options_no_heading), None, None); ··· 891 914 assert!(html_no_options.contains("<h2")); 892 915 assert!(html_no_options.contains("<h3")); 893 916 assert!(html_with_empty_options.contains("id=\"")); 917 + } 918 + 919 + // Helper function to create test shortcodes 920 + fn create_test_shortcodes() -> MarkdownShortcodes { 921 + let mut shortcodes = MarkdownShortcodes::new(); 922 + 923 + shortcodes.register("simple", |_args, _| "SIMPLE_OUTPUT".to_string()); 924 + 925 + shortcodes.register("greet", |args, _| { 926 + let name = args.get_str("name").unwrap_or("World"); 927 + format!("Hello, {}!", name) 928 + }); 929 + 930 + shortcodes.register("date", |args, _| { 931 + let format = args.get_str("format").unwrap_or("default"); 932 + format!("DATE[{}]", format) 933 + }); 934 + 935 + shortcodes.register("highlight", |args, _| { 936 + let lang = args.get_str("lang").unwrap_or("text"); 937 + let body = args.get_str("body").unwrap_or(""); 938 + format!("<code class=\"lang-{}\">{}</code>", lang, body) 939 + }); 940 + 941 + shortcodes.register("alert", |args, _| { 942 + let alert_type = args.get_str("type").unwrap_or("info"); 943 + let body = args.get_str("body").unwrap_or(""); 944 + format!("<div class=\"alert alert-{}\">{}</div>", alert_type, body) 945 + }); 946 + 947 + shortcodes.register("section", |args, _| { 948 + let title = args.get_str("title").unwrap_or(""); 949 + let body = args.get_str("body").unwrap_or(""); 950 + if title.is_empty() { 951 + format!("<section>{}</section>", body) 952 + } else { 953 + format!("<section data-title=\"{}\">{}</section>", title, body) 954 + } 955 + }); 956 + 957 + shortcodes 958 + } 959 + 960 + #[test] 961 + fn test_markdown_with_shortcodes_basic() { 962 + let shortcodes = create_test_shortcodes(); 963 + let options = MarkdownOptions { 964 + shortcodes, 965 + ..Default::default() 966 + }; 967 + 968 + let markdown = "# {{ greet name=Title }}\n\nHello {{ simple }}!"; 969 + let html = render_markdown(markdown, Some(&options), None, None); 970 + 971 + assert!(html.contains("<h1")); 972 + assert!(html.contains("Hello, Title!")); 973 + assert!(html.contains("Hello SIMPLE_OUTPUT!")); 974 + } 975 + 976 + #[test] 977 + fn test_markdown_with_shortcodes_in_headings() { 978 + let shortcodes = create_test_shortcodes(); 979 + let options = MarkdownOptions { 980 + shortcodes, 981 + ..Default::default() 982 + }; 983 + 984 + let markdown = r#"# {{ greet name=Main }} 985 + 986 + ## Section {{ date format=short }} 987 + 988 + ### {{ simple }} Chapter"#; 989 + let html = render_markdown(markdown, Some(&options), None, None); 990 + 991 + assert!(html.contains("<h1")); 992 + assert!(html.contains("Hello, Main!")); 993 + assert!(html.contains("<h2")); 994 + assert!(html.contains("Section DATE[short]")); 995 + assert!(html.contains("<h3")); 996 + assert!(html.contains("SIMPLE_OUTPUT Chapter")); 997 + } 998 + 999 + #[test] 1000 + fn test_markdown_with_shortcodes_in_emphasis() { 1001 + let shortcodes = create_test_shortcodes(); 1002 + let options = MarkdownOptions { 1003 + shortcodes, 1004 + ..Default::default() 1005 + }; 1006 + 1007 + let markdown = "*{{ greet name=Italic }}* and **{{ simple }}**"; 1008 + let html = render_markdown(markdown, Some(&options), None, None); 1009 + 1010 + assert!(html.contains("<em>Hello, Italic!</em>")); 1011 + assert!(html.contains("<strong>SIMPLE_OUTPUT</strong>")); 1012 + } 1013 + 1014 + #[test] 1015 + fn test_markdown_with_shortcodes_in_lists() { 1016 + let shortcodes = create_test_shortcodes(); 1017 + let options = MarkdownOptions { 1018 + shortcodes, 1019 + ..Default::default() 1020 + }; 1021 + 1022 + let markdown = r#"1. {{ greet name=First }} 1023 + 2. {{ simple }} 1024 + 3. {{ date format=iso }}"#; 1025 + let html = render_markdown(markdown, Some(&options), None, None); 1026 + 1027 + assert!(html.contains("<ol>")); 1028 + assert!(html.contains("<li>Hello, First!</li>")); 1029 + assert!(html.contains("<li>SIMPLE_OUTPUT</li>")); 1030 + assert!(html.contains("<li>DATE[iso]</li>")); 1031 + } 1032 + 1033 + #[test] 1034 + fn test_markdown_with_shortcodes_in_tables() { 1035 + let shortcodes = create_test_shortcodes(); 1036 + let options = MarkdownOptions { 1037 + shortcodes, 1038 + ..Default::default() 1039 + }; 1040 + 1041 + let markdown = r#"| Name | Greeting | 1042 + |------|----------| 1043 + | Alice | {{ greet name=Alice }} | 1044 + | Bob | {{ simple }} |"#; 1045 + let html = render_markdown(markdown, Some(&options), None, None); 1046 + 1047 + assert!(html.contains("<table>")); 1048 + assert!(html.contains("<th>Name</th>")); 1049 + assert!(html.contains("<th>Greeting</th>")); 1050 + assert!(html.contains("<td>Alice</td>")); 1051 + assert!(html.contains("<td>Hello, Alice!</td>")); 1052 + assert!(html.contains("<td>Bob</td>")); 1053 + assert!(html.contains("<td>SIMPLE_OUTPUT</td>")); 1054 + } 1055 + 1056 + #[test] 1057 + fn test_markdown_with_shortcodes_in_blockquotes() { 1058 + let shortcodes = create_test_shortcodes(); 1059 + let options = MarkdownOptions { 1060 + shortcodes, 1061 + ..Default::default() 1062 + }; 1063 + 1064 + let markdown = r#"> {{ greet name=Quote }} 1065 + > 1066 + > {{ simple }}"#; 1067 + let html = render_markdown(markdown, Some(&options), None, None); 1068 + 1069 + assert!(html.contains("<blockquote>")); 1070 + assert!(html.contains("Hello, Quote!")); 1071 + assert!(html.contains("SIMPLE_OUTPUT")); 1072 + } 1073 + 1074 + #[test] 1075 + fn test_markdown_with_shortcodes_in_code_blocks() { 1076 + let shortcodes = create_test_shortcodes(); 1077 + let options = MarkdownOptions { 1078 + shortcodes, 1079 + ..Default::default() 1080 + }; 1081 + 1082 + let markdown = r#"```rust 1083 + fn main() { 1084 + println!("{{ greet name=Rust }}"); 1085 + // {{ simple }} 1086 + } 1087 + ```"#; 1088 + let html = render_markdown(markdown, Some(&options), None, None); 1089 + 1090 + assert!(html.contains("<pre")); 1091 + assert!(html.contains("<code")); 1092 + assert!(html.contains("Hello, Rust!")); 1093 + assert!(html.contains("SIMPLE_OUTPUT")); 1094 + } 1095 + 1096 + #[test] 1097 + fn test_markdown_with_shortcodes_in_links() { 1098 + let shortcodes = create_test_shortcodes(); 1099 + let options = MarkdownOptions { 1100 + shortcodes, 1101 + ..Default::default() 1102 + }; 1103 + 1104 + let markdown = r#"[{{ greet name=Link }}](https://example.com "{{ simple }}") 1105 + 1106 + ![{{ greet name=Alt }}](image.jpg "{{ date format=title }}")"#; 1107 + let html = render_markdown(markdown, Some(&options), None, None); 1108 + 1109 + assert!(html.contains("<a href=\"https://example.com\"")); 1110 + assert!(html.contains("title=\"SIMPLE_OUTPUT\"")); 1111 + assert!(html.contains(">Hello, Link!</a>")); 1112 + assert!(html.contains("<img src=\"image.jpg\"")); 1113 + assert!(html.contains("alt=\"Hello, Alt!\"")); 1114 + assert!(html.contains("title=\"DATE[title]\"")); 1115 + } 1116 + 1117 + #[test] 1118 + fn test_markdown_with_block_shortcodes() { 1119 + let shortcodes = create_test_shortcodes(); 1120 + let options = MarkdownOptions { 1121 + shortcodes, 1122 + ..Default::default() 1123 + }; 1124 + 1125 + let markdown = r#"{{ highlight lang=rust }} 1126 + fn main() { 1127 + println!("{{ greet name=World }}"); 1128 + } 1129 + {{ /highlight }}"#; 1130 + let html = render_markdown(markdown, Some(&options), None, None); 1131 + 1132 + assert!(html.contains("<code class=\"lang-rust\">")); 1133 + assert!(html.contains("Hello, World!")); 1134 + assert!(html.contains("</code>")); 1135 + } 1136 + 1137 + #[test] 1138 + fn test_markdown_with_nested_shortcodes() { 1139 + let shortcodes = create_test_shortcodes(); 1140 + let options = MarkdownOptions { 1141 + shortcodes, 1142 + ..Default::default() 1143 + }; 1144 + 1145 + let markdown = r#"{{ alert type=warning }} 1146 + ## {{ greet name=Alert }} 1147 + 1148 + {{ simple }} content here. 1149 + {{ /alert }}"#; 1150 + let html = render_markdown(markdown, Some(&options), None, None); 1151 + 1152 + assert!(html.contains("<div class=\"alert alert-warning\">")); 1153 + // The markdown inside the shortcode becomes raw text, not processed markdown 1154 + assert!(html.contains("## Hello, Alert!")); 1155 + assert!(html.contains("SIMPLE_OUTPUT content")); 1156 + assert!(html.contains("</div>")); 1157 + } 1158 + 1159 + #[test] 1160 + fn test_markdown_with_deeply_nested_shortcodes() { 1161 + let shortcodes = create_test_shortcodes(); 1162 + let options = MarkdownOptions { 1163 + shortcodes, 1164 + ..Default::default() 1165 + }; 1166 + 1167 + let markdown = r#"{{ section title=Main }} 1168 + # {{ greet name=Header }} 1169 + 1170 + {{ alert type=info }} 1171 + **{{ greet name=Bold }}** and *{{ simple }}* 1172 + {{ /alert }} 1173 + {{ /section }}"#; 1174 + let html = render_markdown(markdown, Some(&options), None, None); 1175 + 1176 + assert!(html.contains("<section data-title=\"Main\">")); 1177 + // The markdown inside shortcodes becomes raw text, not processed 1178 + assert!(html.contains("# Hello, Header!")); 1179 + assert!(html.contains("<div class=\"alert alert-info\">")); 1180 + assert!(html.contains("**Hello, Bold!** and *SIMPLE_OUTPUT*")); 1181 + assert!(html.contains("</div>")); 1182 + assert!(html.contains("</section>")); 1183 + } 1184 + 1185 + #[test] 1186 + fn test_markdown_with_shortcodes_in_frontmatter() { 1187 + let shortcodes = create_test_shortcodes(); 1188 + let options = MarkdownOptions { 1189 + shortcodes, 1190 + ..Default::default() 1191 + }; 1192 + 1193 + let markdown = r#"--- 1194 + title: {{ greet name=Blog }} 1195 + date: {{ date format=iso }} 1196 + tags: [{{ simple }}, {{ greet name=Tutorial }}] 1197 + --- 1198 + 1199 + # {{ greet name=Content }} 1200 + 1201 + Welcome to {{ simple }}!"#; 1202 + let html = render_markdown(markdown, Some(&options), None, None); 1203 + 1204 + // The HTML shouldn't contain the frontmatter, but shortcodes in content should be processed 1205 + assert!(!html.contains("---")); 1206 + assert!(!html.contains("title:")); 1207 + assert!(html.contains("<h1")); 1208 + assert!(html.contains("Hello, Content!")); 1209 + assert!(html.contains("Welcome to SIMPLE_OUTPUT!")); 1210 + } 1211 + 1212 + #[test] 1213 + fn test_markdown_with_task_lists_and_shortcodes() { 1214 + let shortcodes = create_test_shortcodes(); 1215 + let options = MarkdownOptions { 1216 + shortcodes, 1217 + ..Default::default() 1218 + }; 1219 + 1220 + let markdown = r#"- [x] {{ greet name=Done }} 1221 + - [ ] {{ simple }} 1222 + - [ ] {{ date format=todo }}"#; 1223 + let html = render_markdown(markdown, Some(&options), None, None); 1224 + 1225 + assert!(html.contains("<ul>")); 1226 + assert!(html.contains("type=\"checkbox\"")); 1227 + assert!(html.contains("checked=\"\"")); 1228 + assert!(html.contains("Hello, Done!")); 1229 + assert!(html.contains("SIMPLE_OUTPUT")); 1230 + assert!(html.contains("DATE[todo]")); 1231 + } 1232 + 1233 + #[test] 1234 + fn test_markdown_with_strikethrough_and_shortcodes() { 1235 + let shortcodes = create_test_shortcodes(); 1236 + let options = MarkdownOptions { 1237 + shortcodes, 1238 + ..Default::default() 1239 + }; 1240 + 1241 + let markdown = "~~{{ greet name=Deleted }}~~ and {{ simple }}"; 1242 + let html = render_markdown(markdown, Some(&options), None, None); 1243 + 1244 + assert!(html.contains("<del>Hello, Deleted!</del>")); 1245 + assert!(html.contains("and SIMPLE_OUTPUT")); 1246 + } 1247 + 1248 + #[test] 1249 + fn test_markdown_real_world_blog_post_with_shortcodes() { 1250 + let shortcodes = create_test_shortcodes(); 1251 + let options = MarkdownOptions { 1252 + shortcodes, 1253 + ..Default::default() 1254 + }; 1255 + 1256 + let markdown = r#"--- 1257 + title: {{ greet name=BlogPost }} 1258 + date: {{ date format=iso }} 1259 + --- 1260 + 1261 + # {{ greet name=Reader }}! 1262 + 1263 + Welcome to my blog about **{{ simple }}**. 1264 + 1265 + ## What we'll cover 1266 + 1267 + 1. {{ greet name=Introduction }} 1268 + 2. {{ simple }} basics 1269 + 3. Advanced {{ greet name=Techniques }} 1270 + 1271 + {{ alert type=info }} 1272 + 💡 **Tip**: Remember {{ greet name=This }}! 1273 + {{ /alert }} 1274 + 1275 + ### Code Example 1276 + 1277 + {{ highlight lang=rust }} 1278 + fn main() { 1279 + println!("{{ greet name=World }}!"); 1280 + } 1281 + {{ /highlight }} 1282 + 1283 + ### Task List 1284 + 1285 + - [x] {{ greet name=Setup }} 1286 + - [ ] {{ simple }} 1287 + - [ ] {{ greet name=Deploy }} 1288 + 1289 + > "{{ greet name=Quote }}" - *{{ simple }}* 1290 + 1291 + Check out [this link](https://example.com "{{ greet name=Title }}")!"#; 1292 + 1293 + let html = render_markdown(markdown, Some(&options), None, None); 1294 + 1295 + // Test various HTML elements are properly rendered with shortcodes 1296 + assert!(html.contains("<h1")); 1297 + assert!(html.contains("Hello, Reader!")); 1298 + assert!(html.contains("<strong>SIMPLE_OUTPUT</strong>")); 1299 + assert!(html.contains("<h2")); 1300 + assert!(html.contains("<ol>")); 1301 + assert!(html.contains("Hello, Introduction!")); 1302 + assert!(html.contains("<div class=\"alert alert-info\">")); 1303 + assert!(html.contains("Remember Hello, This!")); 1304 + assert!(html.contains("<code class=\"lang-rust\">")); 1305 + assert!(html.contains("Hello, World!")); 1306 + assert!(html.contains("<ul>")); 1307 + assert!(html.contains("type=\"checkbox\"")); 1308 + assert!(html.contains("Hello, Setup!")); 1309 + assert!(html.contains("<blockquote>")); 1310 + assert!(html.contains("Hello, Quote!")); 1311 + assert!(html.contains("<a href=\"https://example.com\"")); 1312 + assert!(html.contains("title=\"Hello, Title!\"")); 1313 + } 1314 + 1315 + #[test] 1316 + fn test_markdown_with_math_and_shortcodes() { 1317 + let shortcodes = create_test_shortcodes(); 1318 + let options = MarkdownOptions { 1319 + shortcodes, 1320 + ..Default::default() 1321 + }; 1322 + 1323 + let markdown = r#"Inline math with {{ simple }}: $x = {{ greet name=Variable }}$ 1324 + 1325 + Block math: 1326 + $$ 1327 + {{ greet name=Equation }} 1328 + $$"#; 1329 + let html = render_markdown(markdown, Some(&options), None, None); 1330 + 1331 + assert!(html.contains("SIMPLE_OUTPUT")); 1332 + // Math expressions might be processed differently 1333 + assert!(html.contains("Hello, Variable!")); 1334 + assert!(html.contains("Hello, Equation!")); 894 1335 } 895 1336 }
+17 -1
crates/maudit/src/content/markdown/components.rs
··· 483 483 #[cfg(test)] 484 484 mod tests { 485 485 use super::*; 486 - use crate::content::{render_markdown, MarkdownOptions}; 486 + use crate::content::{MarkdownOptions, render_markdown}; 487 487 488 488 struct TestCustomHeading; 489 489 ··· 617 617 fn test_custom_heading_component() { 618 618 let options = MarkdownOptions { 619 619 components: MarkdownComponents::new().heading(TestCustomHeading), 620 + ..Default::default() 620 621 }; 621 622 622 623 let html = render_markdown("# Hello, world!", Some(&options), None, None); ··· 627 628 fn test_custom_paragraph_component() { 628 629 let options = MarkdownOptions { 629 630 components: MarkdownComponents::new().paragraph(TestCustomParagraph), 631 + ..Default::default() 630 632 }; 631 633 632 634 let content = render_markdown("This is a paragraph.", Some(&options), None, None); ··· 639 641 fn test_custom_link_component() { 640 642 let options = MarkdownOptions { 641 643 components: MarkdownComponents::new().link(TestCustomLink), 644 + ..Default::default() 642 645 }; 643 646 644 647 let content = render_markdown("[Example](https://example.com)", Some(&options), None, None); ··· 651 654 fn test_custom_image_component() { 652 655 let options = MarkdownOptions { 653 656 components: MarkdownComponents::new().image(TestCustomImage), 657 + ..Default::default() 654 658 }; 655 659 656 660 let content = render_markdown("![Alt text](image.jpg)", Some(&options), None, None); ··· 663 667 fn test_custom_strong_component() { 664 668 let options = MarkdownOptions { 665 669 components: MarkdownComponents::new().strong(TestCustomStrong), 670 + ..Default::default() 666 671 }; 667 672 668 673 let content = render_markdown("**Bold text**", Some(&options), None, None); ··· 673 678 fn test_custom_emphasis_component() { 674 679 let options = MarkdownOptions { 675 680 components: MarkdownComponents::new().emphasis(TestCustomEmphasis), 681 + ..Default::default() 676 682 }; 677 683 678 684 let content = render_markdown("*Italic text*", Some(&options), None, None); ··· 683 689 fn test_custom_code_component() { 684 690 let options = MarkdownOptions { 685 691 components: MarkdownComponents::new().code(TestCustomCode), 692 + ..Default::default() 686 693 }; 687 694 688 695 let content = render_markdown("`console.log('hello')`", Some(&options), None, None); ··· 693 700 fn test_custom_blockquote_component() { 694 701 let options = MarkdownOptions { 695 702 components: MarkdownComponents::new().blockquote(TestCustomBlockquote), 703 + ..Default::default() 696 704 }; 697 705 698 706 let content = render_markdown("> This is a quote", Some(&options), None, None); ··· 709 717 .paragraph(TestCustomParagraph) 710 718 .link(TestCustomLink) 711 719 .strong(TestCustomStrong), 720 + ..Default::default() 712 721 }; 713 722 714 723 let content = render_markdown( ··· 734 743 .strong(TestCustomStrong) 735 744 .emphasis(TestCustomEmphasis) 736 745 .code(TestCustomCode), 746 + ..Default::default() 737 747 }; 738 748 739 749 let content = render_markdown( ··· 869 879 fn test_hard_break_component() { 870 880 let options = MarkdownOptions { 871 881 components: MarkdownComponents::new().hard_break(TestHardBreak), 882 + ..Default::default() 872 883 }; 873 884 let content = render_markdown("Line 1 \nLine 2", Some(&options), None, None); 874 885 assert!(content.contains("<br class=\"custom-break\" />")); ··· 878 889 fn test_horizontal_rule_component() { 879 890 let options = MarkdownOptions { 880 891 components: MarkdownComponents::new().horizontal_rule(TestHorizontalRule), 892 + ..Default::default() 881 893 }; 882 894 let content = render_markdown("---", Some(&options), None, None); 883 895 assert!(content.contains("<hr class=\"custom-rule\" />")); ··· 889 901 components: MarkdownComponents::new() 890 902 .list(TestList) 891 903 .list_item(TestListItem), 904 + ..Default::default() 892 905 }; 893 906 let content = render_markdown( 894 907 "1. First\n2. Second\n\n- Bullet\n- Point", ··· 905 918 fn test_strikethrough_component() { 906 919 let options = MarkdownOptions { 907 920 components: MarkdownComponents::new().strikethrough(TestStrikethrough), 921 + ..Default::default() 908 922 }; 909 923 let content = render_markdown("~~strikethrough~~", Some(&options), None, None); 910 924 assert!(content.contains("<del class=\"custom-strike\">")); ··· 914 928 fn test_task_list_component() { 915 929 let options = MarkdownOptions { 916 930 components: MarkdownComponents::new().task_list_marker(TestTaskListMarker), 931 + ..Default::default() 917 932 }; 918 933 let content = render_markdown("- [x] Done\n- [ ] Todo", Some(&options), None, None); 919 934 assert!(content.contains("<input type=\"checkbox\" checked class=\"custom-task\" />")); ··· 928 943 .table_head(TestTableHead) 929 944 .table_row(TestTableRow) 930 945 .table_cell(TestTableCell), 946 + ..Default::default() 931 947 }; 932 948 let content = render_markdown( 933 949 "| Header | Header |\n|--------|--------|\n| Cell | Cell |",
+334
crates/maudit/src/content/markdown/shortcodes.rs
··· 1 + use rustc_hash::FxHashMap; 2 + use std::str::FromStr; 3 + 4 + use crate::page::RouteContext; 5 + 6 + pub type ShortcodeFn = 7 + Box<dyn Fn(&ShortcodeArgs, Option<&mut RouteContext>) -> String + Send + Sync>; 8 + 9 + #[derive(Default)] 10 + pub struct MarkdownShortcodes(FxHashMap<String, ShortcodeFn>); 11 + 12 + impl MarkdownShortcodes { 13 + pub fn new() -> Self { 14 + Self(FxHashMap::default()) 15 + } 16 + 17 + pub fn register<F>(&mut self, name: &str, func: F) 18 + where 19 + F: Fn(&ShortcodeArgs, Option<&mut RouteContext>) -> String + Send + Sync + 'static, 20 + { 21 + self.0.insert(name.to_string(), Box::new(func)); 22 + } 23 + 24 + pub(crate) fn get(&self, name: &str) -> Option<&ShortcodeFn> { 25 + self.0.get(name) 26 + } 27 + 28 + pub(crate) fn is_empty(&self) -> bool { 29 + self.0.is_empty() 30 + } 31 + } 32 + 33 + // Helper function to validate shortcode names 34 + // Valid names match ^[A-Za-z_][0-9A-Za-z_]+$ pattern 35 + fn is_valid_shortcode_name(name: &str) -> bool { 36 + if name.len() < 2 { 37 + return false; // Must have at least 2 characters 38 + } 39 + 40 + let mut chars = name.chars(); 41 + 42 + // First character must be A-Z, a-z, or _ 43 + let first = chars.next().unwrap(); 44 + if !first.is_ascii_alphabetic() && first != '_' { 45 + return false; 46 + } 47 + 48 + // Remaining characters must be A-Z, a-z, 0-9, or _ 49 + for ch in chars { 50 + if !ch.is_ascii_alphanumeric() && ch != '_' { 51 + return false; 52 + } 53 + } 54 + 55 + true 56 + } 57 + 58 + pub fn preprocess_shortcodes( 59 + content: &str, 60 + shortcodes: &MarkdownShortcodes, 61 + mut route_ctx: Option<&mut RouteContext>, 62 + ) -> Result<String, String> { 63 + let mut output = String::new(); 64 + let mut rest = content; 65 + 66 + while let Some(start) = rest.find("{{") { 67 + // Check for escaped shortcode syntax like `\{{` - if found, skip this occurrence 68 + if start > 0 && rest.chars().nth(start - 1) == Some('\\') { 69 + // This is an escaped shortcode, add everything up to and including the {{ 70 + output.push_str(&rest[..start + 2]); 71 + rest = &rest[start + 2..]; 72 + continue; 73 + } 74 + 75 + // Add everything before the shortcode 76 + output.push_str(&rest[..start]); 77 + 78 + // Find the end of the opening shortcode tag 79 + let remaining = &rest[start + 2..]; 80 + let Some(tag_end) = remaining.find("}}") else { 81 + // No closing }}, treat as literal text 82 + output.push_str("{{"); 83 + rest = remaining; 84 + continue; 85 + }; 86 + 87 + let shortcode_content = remaining[..tag_end].trim(); 88 + 89 + // Parse shortcode name and arguments 90 + let mut parts = shortcode_content.split_whitespace(); 91 + let name = parts.next().ok_or("Empty shortcode")?; 92 + 93 + // Check if this is a closing tag 94 + if name.starts_with('/') { 95 + return Err(format!("Unexpected closing tag: {}", name)); 96 + } 97 + 98 + // Validate shortcode name format 99 + let actual_name = name.strip_prefix('/').unwrap_or(name); 100 + 101 + if !is_valid_shortcode_name(actual_name) { 102 + // Invalid shortcode name, treat as literal text and continue 103 + output.push_str("{{"); 104 + rest = remaining; 105 + continue; 106 + } 107 + 108 + // Parse arguments with support for quoted values 109 + let mut args = FxHashMap::default(); 110 + let args_str = parts.collect::<Vec<_>>().join(" "); 111 + 112 + if !args_str.is_empty() { 113 + let mut chars = args_str.chars().peekable(); 114 + let mut current_key = String::new(); 115 + let mut current_value = String::new(); 116 + let mut in_key = true; 117 + let mut in_quotes = false; 118 + let mut quote_char = ' '; 119 + 120 + while let Some(ch) = chars.next() { 121 + match ch { 122 + '=' if in_key && !in_quotes => { 123 + in_key = false; 124 + // Check if next char is a quote 125 + if let Some(&next_ch) = chars.peek() 126 + && (next_ch == '"' || next_ch == '\'') 127 + { 128 + quote_char = next_ch; 129 + in_quotes = true; 130 + chars.next(); // consume the quote 131 + } 132 + } 133 + '"' | '\'' if !in_key && in_quotes && ch == quote_char => { 134 + // End of quoted value 135 + in_quotes = false; 136 + args.insert(current_key.trim().to_string(), current_value.clone()); 137 + current_key.clear(); 138 + current_value.clear(); 139 + in_key = true; 140 + 141 + // Skip any whitespace after the closing quote 142 + while let Some(&next_ch) = chars.peek() { 143 + if next_ch.is_whitespace() { 144 + chars.next(); 145 + } else { 146 + break; 147 + } 148 + } 149 + } 150 + ' ' if !in_quotes => { 151 + if !in_key && !current_value.is_empty() { 152 + // End of unquoted value 153 + args.insert( 154 + current_key.trim().to_string(), 155 + current_value.trim().to_string(), 156 + ); 157 + current_key.clear(); 158 + current_value.clear(); 159 + in_key = true; 160 + } else if in_key && !current_key.is_empty() { 161 + return Err(format!( 162 + "Invalid argument format: '{}'. Expected 'key=value'", 163 + current_key 164 + )); 165 + } 166 + // Skip multiple spaces 167 + while let Some(&next_ch) = chars.peek() { 168 + if next_ch == ' ' { 169 + chars.next(); 170 + } else { 171 + break; 172 + } 173 + } 174 + } 175 + _ => { 176 + if in_key { 177 + current_key.push(ch); 178 + } else { 179 + current_value.push(ch); 180 + } 181 + } 182 + } 183 + } 184 + 185 + // Handle the last argument if there's one pending 186 + if !in_key && (!current_value.is_empty() || !in_quotes) { 187 + if in_quotes { 188 + return Err("Unclosed quote in argument value".to_string()); 189 + } 190 + args.insert( 191 + current_key.trim().to_string(), 192 + current_value.trim().to_string(), 193 + ); 194 + } else if !current_key.trim().is_empty() { 195 + return Err(format!( 196 + "Invalid argument format: '{}'. Expected 'key=value'", 197 + current_key.trim() 198 + )); 199 + } 200 + } 201 + 202 + // Move past the opening tag 203 + let after_opening_tag = &remaining[tag_end + 2..]; 204 + 205 + // Look for closing tag - handle both {{/name}} and {{ /name }} formats 206 + let closing_tag_compact = format!("{{{{/{}}}}}", name); 207 + let closing_tag_spaced = format!("{{{{ /{} }}}}", name); 208 + 209 + let close_pos = after_opening_tag 210 + .find(&closing_tag_compact) 211 + .or_else(|| after_opening_tag.find(&closing_tag_spaced)); 212 + 213 + if let Some(close_pos) = close_pos { 214 + // Determine which closing tag format was found to calculate the correct length 215 + let closing_tag_len = 216 + if after_opening_tag[close_pos..].starts_with(&closing_tag_compact) { 217 + closing_tag_compact.len() 218 + } else { 219 + closing_tag_spaced.len() 220 + }; 221 + 222 + // Block shortcode - extract body and recursively process it 223 + let body = &after_opening_tag[..close_pos]; 224 + let processed_body = preprocess_shortcodes(body, shortcodes, route_ctx.as_deref_mut())?; 225 + 226 + // Execute shortcode with processed body 227 + if let Some(func) = shortcodes.get(name) { 228 + let mut shortcode_args = ShortcodeArgs::new(args); 229 + shortcode_args.0.insert("body".to_string(), processed_body); 230 + let result = func(&shortcode_args, route_ctx.as_deref_mut()); 231 + output.push_str(&result); 232 + } else { 233 + return Err(format!("Unknown shortcode: '{}'", name)); 234 + } 235 + 236 + // Continue after the closing tag 237 + rest = &after_opening_tag[close_pos + closing_tag_len..]; 238 + } else { 239 + // Self-closing shortcode 240 + if let Some(func) = shortcodes.get(name) { 241 + let shortcode_args = ShortcodeArgs::new(args); 242 + let result = func(&shortcode_args, route_ctx.as_deref_mut()); 243 + output.push_str(&result); 244 + } else { 245 + return Err(format!("Unknown shortcode: '{}'", name)); 246 + } 247 + 248 + // Continue after the opening tag 249 + rest = after_opening_tag; 250 + } 251 + } 252 + 253 + output.push_str(rest); 254 + Ok(output) 255 + } 256 + 257 + pub struct ShortcodeArgs(FxHashMap<String, String>); 258 + 259 + impl ShortcodeArgs { 260 + pub fn new(args: FxHashMap<String, String>) -> Self { 261 + Self(args) 262 + } 263 + 264 + /// Get argument with automatic type conversion 265 + pub fn get<T>(&self, key: &str) -> Option<T> 266 + where 267 + T: FromStr, 268 + T::Err: std::fmt::Debug, 269 + { 270 + self.0.get(key)?.parse().ok() 271 + } 272 + 273 + /// Get required argument with automatic type conversion 274 + pub fn get_required<T>(&self, key: &str) -> T 275 + where 276 + T: FromStr, 277 + T::Err: std::fmt::Debug, 278 + { 279 + self.0 280 + .get(key) 281 + .unwrap_or_else(|| panic!("Required argument '{}' not found", key)) 282 + .parse() 283 + .unwrap_or_else(|e| panic!("Failed to parse argument '{}': {:?}", key, e)) 284 + } 285 + 286 + /// Get argument with default value and type conversion 287 + pub fn get_or<T>(&self, key: &str, default: T) -> T 288 + where 289 + T: FromStr, 290 + T::Err: std::fmt::Debug, 291 + { 292 + self.0 293 + .get(key) 294 + .and_then(|s| s.parse().ok()) 295 + .unwrap_or(default) 296 + } 297 + 298 + /// Get raw string (no conversion) 299 + pub fn get_str(&self, key: &str) -> Option<&str> { 300 + self.0.get(key).map(|s| s.as_str()) 301 + } 302 + 303 + pub fn get_str_required(&self, key: &str) -> &str { 304 + self.0 305 + .get(key) 306 + .map(|s| s.as_str()) 307 + .unwrap_or_else(|| panic!("Required argument '{}' not found", key)) 308 + } 309 + 310 + pub fn get_str_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str { 311 + self.0.get(key).map(|s| s.as_str()).unwrap_or(default) 312 + } 313 + } 314 + 315 + // Macro to make typed shortcodes easier to write 316 + #[macro_export] 317 + macro_rules! shortcode { 318 + ($args:ident, $($param:ident: $type:ty),* => $body:expr) => { 319 + |$args: &ShortcodeArgs| -> String { 320 + $( 321 + let $param: $type = $args.get_required(stringify!($param)); 322 + )* 323 + $body 324 + } 325 + }; 326 + ($args:ident, $($param:ident: $type:ty = $default:expr),* => $body:expr) => { 327 + |$args: &ShortcodeArgs| -> String { 328 + $( 329 + let $param: $type = $args.get_or(stringify!($param), $default); 330 + )* 331 + $body 332 + } 333 + }; 334 + }
+1135
crates/maudit/src/content/markdown/shortcodes_tests.rs
··· 1 + #[cfg(test)] 2 + mod tests { 3 + use crate::{ 4 + content::shortcodes::{MarkdownShortcodes, preprocess_shortcodes}, 5 + page::RouteContext, 6 + }; 7 + 8 + fn create_test_shortcodes() -> MarkdownShortcodes { 9 + let mut shortcodes = MarkdownShortcodes::new(); 10 + 11 + // Simple shortcode that just returns its name 12 + shortcodes.register("simple", |_args, _| "SIMPLE_OUTPUT".to_string()); 13 + 14 + // Shortcode with arguments 15 + shortcodes.register("greet", |args, _| { 16 + let name = args.get_str("name").unwrap_or("World"); 17 + format!("Hello, {}!", name) 18 + }); 19 + 20 + // Date shortcode with format 21 + shortcodes.register("date", |args, _| { 22 + let format = args.get_str("format").unwrap_or("default"); 23 + format!("DATE[{}]", format) 24 + }); 25 + 26 + // Block shortcode that wraps content 27 + shortcodes.register("highlight", |args, _| { 28 + let lang = args.get_str("lang").unwrap_or("text"); 29 + let body = args.get_str("body").unwrap_or(""); 30 + format!("<code lang=\"{}\">{}</code>", lang, body) 31 + }); 32 + 33 + // Section shortcode for testing nested content 34 + shortcodes.register("section", |args, _| { 35 + let title = args.get_str("title").unwrap_or(""); 36 + let body = args.get_str("body").unwrap_or(""); 37 + if title.is_empty() { 38 + format!("<section>{}</section>", body) 39 + } else { 40 + format!("<section title=\"{}\">{}</section>", title, body) 41 + } 42 + }); 43 + 44 + // Alert shortcode with type 45 + shortcodes.register("alert", |args, _| { 46 + let alert_type = args.get_str("type").unwrap_or("info"); 47 + let body = args.get_str("body").unwrap_or(""); 48 + format!("<div class=\"alert alert-{}\">{}</div>", alert_type, body) 49 + }); 50 + 51 + shortcodes 52 + } 53 + 54 + // Helper function to create a minimal RouteContext for testing 55 + fn with_test_route_context<F, R>(f: F) -> R 56 + where 57 + F: for<'a> FnOnce(&mut RouteContext<'a>) -> R, 58 + { 59 + use crate::{ 60 + assets::PageAssets, 61 + content::{Content, ContentSources}, 62 + page::RouteParams, 63 + }; 64 + use rustc_hash::FxHashMap; 65 + use std::path::PathBuf; 66 + 67 + let params = RouteParams(FxHashMap::default()); 68 + let content_sources = ContentSources::new(vec![]); 69 + let content = Content::new(&content_sources.0); 70 + let mut page_assets = PageAssets { 71 + assets_dir: PathBuf::from("assets"), 72 + ..Default::default() 73 + }; 74 + 75 + let mut ctx = RouteContext { 76 + raw_params: &params, 77 + content: &content, 78 + assets: &mut page_assets, 79 + current_url: "/test".to_string(), 80 + params: &(), 81 + props: &(), 82 + }; 83 + 84 + f(&mut ctx) 85 + } 86 + 87 + // Helper function for tests that don't need RouteContext 88 + fn preprocess_shortcodes_simple( 89 + content: &str, 90 + shortcodes: &MarkdownShortcodes, 91 + ) -> Result<String, String> { 92 + preprocess_shortcodes(content, shortcodes, None) 93 + } 94 + 95 + // Helper function that automatically wraps RouteContext in Some() for existing tests 96 + fn preprocess_shortcodes_with_ctx( 97 + content: &str, 98 + shortcodes: &MarkdownShortcodes, 99 + route_ctx: &mut RouteContext, 100 + ) -> Result<String, String> { 101 + preprocess_shortcodes(content, shortcodes, Some(route_ctx)) 102 + } 103 + 104 + #[test] 105 + fn test_no_shortcodes() { 106 + let shortcodes = create_test_shortcodes(); 107 + let content = "This is just plain text with no shortcodes."; 108 + let result = with_test_route_context(|route_ctx| { 109 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx).unwrap() 110 + }); 111 + assert_eq!(result, content); 112 + } 113 + 114 + #[test] 115 + fn test_simple_self_closing_shortcode() { 116 + let shortcodes = create_test_shortcodes(); 117 + let content = "Before {{ simple }} after"; 118 + let result = with_test_route_context(|route_ctx| { 119 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx).unwrap() 120 + }); 121 + assert_eq!(result, "Before SIMPLE_OUTPUT after"); 122 + } 123 + 124 + #[test] 125 + fn test_shortcode_with_arguments() { 126 + let shortcodes = create_test_shortcodes(); 127 + let content = "{{ greet name=Alice }}"; 128 + let result = with_test_route_context(|route_ctx| { 129 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx).unwrap() 130 + }); 131 + assert_eq!(result, "Hello, Alice!"); 132 + } 133 + 134 + #[test] 135 + fn test_multiple_arguments() { 136 + let shortcodes = create_test_shortcodes(); 137 + let content = "{{ date format=iso year=2023 }}"; 138 + let result = with_test_route_context(|route_ctx| { 139 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx).unwrap() 140 + }); 141 + assert_eq!(result, "DATE[iso]"); 142 + } 143 + 144 + #[test] 145 + fn test_frontmatter_shortcodes() { 146 + let shortcodes = create_test_shortcodes(); 147 + let content = r#"--- 148 + title: {{ greet name=Blog }} 149 + date: {{ date format=iso }} 150 + --- 151 + 152 + # Content here"#; 153 + let result = with_test_route_context(|route_ctx| { 154 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx).unwrap() 155 + }); 156 + let expected = r#"--- 157 + title: Hello, Blog! 158 + date: DATE[iso] 159 + --- 160 + 161 + # Content here"#; 162 + assert_eq!(result, expected); 163 + } 164 + 165 + #[test] 166 + fn test_shortcodes_in_headings() { 167 + let shortcodes = create_test_shortcodes(); 168 + let content = "# {{ greet name=Header }}\n\n## Section {{ date format=short }}"; 169 + let result = with_test_route_context(|route_ctx| { 170 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx).unwrap() 171 + }); 172 + assert_eq!(result, "# Hello, Header!\n\n## Section DATE[short]"); 173 + } 174 + 175 + #[test] 176 + fn test_shortcodes_in_links() { 177 + let shortcodes = create_test_shortcodes(); 178 + let content = "[{{ greet name=Link }}](https://example.com)"; 179 + let result = with_test_route_context(|route_ctx| { 180 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx).unwrap() 181 + }); 182 + assert_eq!(result, "[Hello, Link!](https://example.com)"); 183 + } 184 + 185 + #[test] 186 + fn test_shortcodes_in_code_blocks() { 187 + let shortcodes = create_test_shortcodes(); 188 + let content = "```\nSome code with {{ simple }}\n```"; 189 + let result = with_test_route_context(|route_ctx| { 190 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx).unwrap() 191 + }); 192 + assert_eq!(result, "```\nSome code with SIMPLE_OUTPUT\n```"); 193 + } 194 + 195 + #[test] 196 + fn test_block_shortcode() { 197 + let shortcodes = create_test_shortcodes(); 198 + let content = "{{ highlight lang=rust }}\nlet x = 5;\n{{ /highlight }}"; 199 + let result = with_test_route_context(|route_ctx| { 200 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx).unwrap() 201 + }); 202 + assert_eq!(result, "<code lang=\"rust\">\nlet x = 5;\n</code>"); 203 + } 204 + 205 + #[test] 206 + fn test_nested_shortcodes_in_block() { 207 + let shortcodes = create_test_shortcodes(); 208 + let content = "{{ section title=Main }}\nHello {{ greet name=World }}!\n{{ /section }}"; 209 + let result = with_test_route_context(|route_ctx| { 210 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 211 + }) 212 + .unwrap(); 213 + assert_eq!( 214 + result, 215 + "<section title=\"Main\">\nHello Hello, World!!\n</section>" 216 + ); 217 + } 218 + 219 + #[test] 220 + fn test_deeply_nested_shortcodes() { 221 + let shortcodes = create_test_shortcodes(); 222 + let content = r#"{{ section title=Outer }} 223 + {{ alert type=warning }} 224 + {{ highlight lang=javascript }} 225 + console.log("{{ greet name=Nested }}"); 226 + {{ /highlight }} 227 + {{ /alert }} 228 + {{ /section }}"#; 229 + let result = with_test_route_context(|route_ctx| { 230 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 231 + }) 232 + .unwrap(); 233 + let expected = r#"<section title="Outer"> 234 + <div class="alert alert-warning"> 235 + <code lang="javascript"> 236 + console.log("Hello, Nested!"); 237 + </code> 238 + </div> 239 + </section>"#; 240 + assert_eq!(result, expected); 241 + } 242 + 243 + #[test] 244 + fn test_multiple_shortcodes_same_line() { 245 + let shortcodes = create_test_shortcodes(); 246 + let content = "{{ greet name=Alice }} and {{ greet name=Bob }}"; 247 + let result = with_test_route_context(|route_ctx| { 248 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 249 + }) 250 + .unwrap(); 251 + assert_eq!(result, "Hello, Alice! and Hello, Bob!"); 252 + } 253 + 254 + #[test] 255 + fn test_shortcodes_in_lists() { 256 + let shortcodes = create_test_shortcodes(); 257 + let content = r#"- Item 1: {{ greet name=First }} 258 + - Item 2: {{ date format=short }} 259 + - Item 3: {{ simple }}"#; 260 + let result = with_test_route_context(|route_ctx| { 261 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 262 + }) 263 + .unwrap(); 264 + let expected = r#"- Item 1: Hello, First! 265 + - Item 2: DATE[short] 266 + - Item 3: SIMPLE_OUTPUT"#; 267 + assert_eq!(result, expected); 268 + } 269 + 270 + #[test] 271 + fn test_shortcodes_in_tables() { 272 + let shortcodes = create_test_shortcodes(); 273 + let content = r#"| Name | Greeting | 274 + |------|----------| 275 + | Alice | {{ greet name=Alice }} | 276 + | Bob | {{ greet name=Bob }} |"#; 277 + let result = with_test_route_context(|route_ctx| { 278 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 279 + }) 280 + .unwrap(); 281 + let expected = r#"| Name | Greeting | 282 + |------|----------| 283 + | Alice | Hello, Alice! | 284 + | Bob | Hello, Bob! |"#; 285 + assert_eq!(result, expected); 286 + } 287 + 288 + #[test] 289 + fn test_shortcodes_with_special_characters() { 290 + let shortcodes = create_test_shortcodes(); 291 + let content = "Before\n{{ simple }}\nAfter\n\n{{ greet name=Test }}"; 292 + let result = with_test_route_context(|route_ctx| { 293 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 294 + }) 295 + .unwrap(); 296 + assert_eq!(result, "Before\nSIMPLE_OUTPUT\nAfter\n\nHello, Test!"); 297 + } 298 + 299 + #[test] 300 + fn test_error_unknown_shortcode() { 301 + let shortcodes = create_test_shortcodes(); 302 + let content = "{{ unknown_shortcode }}"; 303 + let result = with_test_route_context(|route_ctx| { 304 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 305 + }); 306 + assert!(result.is_err()); 307 + assert!( 308 + result 309 + .unwrap_err() 310 + .contains("Unknown shortcode: 'unknown_shortcode'") 311 + ); 312 + } 313 + 314 + #[test] 315 + fn test_unclosed_shortcode_treated_as_literal() { 316 + let shortcodes = create_test_shortcodes(); 317 + let content = "{{ simple "; 318 + let result = with_test_route_context(|route_ctx| { 319 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 320 + }) 321 + .unwrap(); 322 + // Should treat as literal text since there's no closing }} 323 + assert_eq!(result, "{{ simple "); 324 + } 325 + 326 + #[test] 327 + fn test_unclosed_shortcode_with_valid_shortcode_after() { 328 + let shortcodes = create_test_shortcodes(); 329 + let content = "Before {{ unclosed. Then {{ simple }} after."; 330 + let result = with_test_route_context(|route_ctx| { 331 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 332 + }) 333 + .unwrap(); 334 + // Should treat first {{ as literal and process the second shortcode 335 + assert_eq!(result, "Before {{ unclosed. Then SIMPLE_OUTPUT after."); 336 + } 337 + 338 + #[test] 339 + fn test_multiple_unclosed_shortcodes() { 340 + let shortcodes = create_test_shortcodes(); 341 + let content = "{{ first {{ second {{ third"; 342 + let result = with_test_route_context(|route_ctx| { 343 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 344 + }) 345 + .unwrap(); 346 + // All should be treated as literal text 347 + assert_eq!(result, "{{ first {{ second {{ third"); 348 + } 349 + 350 + #[test] 351 + fn test_error_empty_shortcode() { 352 + let shortcodes = create_test_shortcodes(); 353 + let content = "{{ }}"; 354 + let result = with_test_route_context(|route_ctx| { 355 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 356 + }); 357 + assert!(result.is_err()); 358 + assert!(result.unwrap_err().contains("Empty shortcode")); 359 + } 360 + 361 + #[test] 362 + fn test_error_invalid_argument_format() { 363 + let shortcodes = create_test_shortcodes(); 364 + let content = "{{ greet name Alice }}"; 365 + let result = with_test_route_context(|route_ctx| { 366 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 367 + }); 368 + assert!(result.is_err()); 369 + assert!(result.unwrap_err().contains("Invalid argument format")); 370 + } 371 + 372 + #[test] 373 + fn test_error_unexpected_closing_tag() { 374 + let shortcodes = create_test_shortcodes(); 375 + let content = "{{ /section }}"; 376 + let result = with_test_route_context(|route_ctx| { 377 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 378 + }); 379 + assert!(result.is_err()); 380 + assert!(result.unwrap_err().contains("Unexpected closing tag")); 381 + } 382 + 383 + #[test] 384 + fn test_whitespace_handling() { 385 + let shortcodes = create_test_shortcodes(); 386 + let content = "{{ simple }}"; 387 + let result = with_test_route_context(|route_ctx| { 388 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 389 + }) 390 + .unwrap(); 391 + assert_eq!(result, "SIMPLE_OUTPUT"); 392 + } 393 + 394 + #[test] 395 + fn test_whitespace_in_arguments() { 396 + let shortcodes = create_test_shortcodes(); 397 + let content = "{{ greet name=Alice }}"; 398 + let result = with_test_route_context(|route_ctx| { 399 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 400 + }) 401 + .unwrap(); 402 + assert_eq!(result, "Hello, Alice!"); 403 + } 404 + 405 + #[test] 406 + fn test_complex_markdown_document() { 407 + let shortcodes = create_test_shortcodes(); 408 + let content = r#"--- 409 + title: {{ greet name=Blog }} 410 + author: {{ greet name=Author }} 411 + --- 412 + 413 + # {{ greet name=Reader }} 414 + 415 + Welcome to my blog! Today is {{ date format=full }}. 416 + 417 + ## Code Example 418 + 419 + {{ highlight lang=rust }} 420 + fn main() { 421 + println!("{{ greet name=Rust }}"); 422 + } 423 + {{ /highlight }} 424 + 425 + ## Alert Section 426 + 427 + {{ alert type=info }} 428 + This is an important message with {{ simple }} content. 429 + {{ /alert }} 430 + 431 + - List item with {{ greet name=Item }} 432 + - Another item: {{ date format=short }} 433 + 434 + > Quote with {{ simple }} shortcode 435 + 436 + [Link with {{ greet name=Link }}](http://example.com)"#; 437 + 438 + let result = with_test_route_context(|route_ctx| { 439 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 440 + }) 441 + .unwrap(); 442 + let expected = r#"--- 443 + title: Hello, Blog! 444 + author: Hello, Author! 445 + --- 446 + 447 + # Hello, Reader! 448 + 449 + Welcome to my blog! Today is DATE[full]. 450 + 451 + ## Code Example 452 + 453 + <code lang="rust"> 454 + fn main() { 455 + println!("Hello, Rust!"); 456 + } 457 + </code> 458 + 459 + ## Alert Section 460 + 461 + <div class="alert alert-info"> 462 + This is an important message with SIMPLE_OUTPUT content. 463 + </div> 464 + 465 + - List item with Hello, Item! 466 + - Another item: DATE[short] 467 + 468 + > Quote with SIMPLE_OUTPUT shortcode 469 + 470 + [Link with Hello, Link!](http://example.com)"#; 471 + assert_eq!(result, expected); 472 + } 473 + 474 + // Integration tests with full markdown rendering 475 + #[test] 476 + fn test_markdown_integration_headings_with_shortcodes() { 477 + let shortcodes = create_test_shortcodes(); 478 + let content = "# {{ greet name=Title }}\n\n## Section {{ date format=short }}"; 479 + 480 + // Test shortcode preprocessing first 481 + let processed = with_test_route_context(|route_ctx| { 482 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 483 + }) 484 + .unwrap(); 485 + assert_eq!(processed, "# Hello, Title!\n\n## Section DATE[short]"); 486 + } 487 + 488 + #[test] 489 + fn test_markdown_integration_emphasis_with_shortcodes() { 490 + let shortcodes = create_test_shortcodes(); 491 + let content = "*{{ greet name=Italic }}* and **{{ greet name=Bold }}**"; 492 + let processed = with_test_route_context(|route_ctx| { 493 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 494 + }) 495 + .unwrap(); 496 + assert_eq!(processed, "*Hello, Italic!* and **Hello, Bold!**"); 497 + } 498 + 499 + #[test] 500 + fn test_markdown_integration_code_spans_with_shortcodes() { 501 + let shortcodes = create_test_shortcodes(); 502 + let content = "Use `{{ simple }}` in your code"; 503 + let processed = with_test_route_context(|route_ctx| { 504 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 505 + }) 506 + .unwrap(); 507 + assert_eq!(processed, "Use `SIMPLE_OUTPUT` in your code"); 508 + } 509 + 510 + #[test] 511 + fn test_markdown_integration_blockquotes_with_shortcodes() { 512 + let shortcodes = create_test_shortcodes(); 513 + let content = "> {{ greet name=Quote }}\n> \n> {{ simple }}"; 514 + let processed = with_test_route_context(|route_ctx| { 515 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 516 + }) 517 + .unwrap(); 518 + assert_eq!(processed, "> Hello, Quote!\n> \n> SIMPLE_OUTPUT"); 519 + } 520 + 521 + #[test] 522 + fn test_markdown_integration_nested_lists_with_shortcodes() { 523 + let shortcodes = create_test_shortcodes(); 524 + let content = r#"1. {{ greet name=First }} 525 + - Nested {{ simple }} 526 + - {{ date format=iso }} 527 + 2. {{ greet name=Second }} 528 + 1. Numbered {{ simple }} 529 + 2. {{ greet name=Nested }}"#; 530 + let processed = with_test_route_context(|route_ctx| { 531 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 532 + }) 533 + .unwrap(); 534 + let expected = r#"1. Hello, First! 535 + - Nested SIMPLE_OUTPUT 536 + - DATE[iso] 537 + 2. Hello, Second! 538 + 1. Numbered SIMPLE_OUTPUT 539 + 2. Hello, Nested!"#; 540 + assert_eq!(processed, expected); 541 + } 542 + 543 + #[test] 544 + fn test_markdown_integration_complex_tables_with_shortcodes() { 545 + let shortcodes = create_test_shortcodes(); 546 + let content = r#"| **{{ greet name=Header }}** | _{{ date format=long }}_ | 547 + |:---------------------------|-------------------------:| 548 + | {{ simple }} | {{ greet name=Cell }} | 549 + | `{{ greet name=Code }}` | > {{ simple }} |"#; 550 + let processed = with_test_route_context(|route_ctx| { 551 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 552 + }) 553 + .unwrap(); 554 + let expected = r#"| **Hello, Header!** | _DATE[long]_ | 555 + |:---------------------------|-------------------------:| 556 + | SIMPLE_OUTPUT | Hello, Cell! | 557 + | `Hello, Code!` | > SIMPLE_OUTPUT |"#; 558 + assert_eq!(processed, expected); 559 + } 560 + 561 + #[test] 562 + fn test_markdown_integration_task_lists_with_shortcodes() { 563 + let shortcodes = create_test_shortcodes(); 564 + let content = r#"- [x] {{ greet name=Done }} 565 + - [ ] {{ simple }} 566 + - [ ] {{ date format=todo }}"#; 567 + let processed = with_test_route_context(|route_ctx| { 568 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 569 + }) 570 + .unwrap(); 571 + let expected = r#"- [x] Hello, Done! 572 + - [ ] SIMPLE_OUTPUT 573 + - [ ] DATE[todo]"#; 574 + assert_eq!(processed, expected); 575 + } 576 + 577 + #[test] 578 + fn test_markdown_integration_strikethrough_with_shortcodes() { 579 + let shortcodes = create_test_shortcodes(); 580 + let content = "~~{{ greet name=Deleted }}~~ and {{ simple }}"; 581 + let processed = with_test_route_context(|route_ctx| { 582 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 583 + }) 584 + .unwrap(); 585 + assert_eq!(processed, "~~Hello, Deleted!~~ and SIMPLE_OUTPUT"); 586 + } 587 + 588 + #[test] 589 + fn test_markdown_integration_horizontal_rules_with_shortcodes() { 590 + let shortcodes = create_test_shortcodes(); 591 + let content = "{{ greet name=Before }}\n\n---\n\n{{ simple }}"; 592 + let processed = with_test_route_context(|route_ctx| { 593 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 594 + }) 595 + .unwrap(); 596 + assert_eq!(processed, "Hello, Before!\n\n---\n\nSIMPLE_OUTPUT"); 597 + } 598 + 599 + #[test] 600 + fn test_markdown_integration_footnotes_with_shortcodes() { 601 + let shortcodes = create_test_shortcodes(); 602 + let content = "{{ greet name=Text }}[^1]\n\n[^1]: {{ simple }}"; 603 + let processed = with_test_route_context(|route_ctx| { 604 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 605 + }) 606 + .unwrap(); 607 + assert_eq!(processed, "Hello, Text![^1]\n\n[^1]: SIMPLE_OUTPUT"); 608 + } 609 + 610 + #[test] 611 + fn test_markdown_integration_complex_links_with_shortcodes() { 612 + let shortcodes = create_test_shortcodes(); 613 + let content = r#"[{{ greet name=Link }}](https://example.com "{{ simple }}") 614 + 615 + ![{{ greet name=Alt }}](image.jpg "{{ date format=title }}") 616 + 617 + [Reference {{ simple }}][ref] 618 + 619 + [ref]: https://example.com "{{ greet name=RefTitle }}""#; 620 + let processed = with_test_route_context(|route_ctx| { 621 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 622 + }) 623 + .unwrap(); 624 + let expected = r#"[Hello, Link!](https://example.com "SIMPLE_OUTPUT") 625 + 626 + ![Hello, Alt!](image.jpg "DATE[title]") 627 + 628 + [Reference SIMPLE_OUTPUT][ref] 629 + 630 + [ref]: https://example.com "Hello, RefTitle!""#; 631 + assert_eq!(processed, expected); 632 + } 633 + 634 + #[test] 635 + fn test_markdown_integration_fenced_code_blocks_with_shortcodes() { 636 + let shortcodes = create_test_shortcodes(); 637 + let content = r#"```rust 638 + fn main() { 639 + println!("{{ greet name=Rust }}"); 640 + // {{ simple }} 641 + } 642 + ``` 643 + 644 + ```{{ greet name=Language }} 645 + {{ simple }} 646 + ```"#; 647 + let processed = with_test_route_context(|route_ctx| { 648 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 649 + }) 650 + .unwrap(); 651 + let expected = r#"```rust 652 + fn main() { 653 + println!("Hello, Rust!"); 654 + // SIMPLE_OUTPUT 655 + } 656 + ``` 657 + 658 + ```Hello, Language! 659 + SIMPLE_OUTPUT 660 + ```"#; 661 + assert_eq!(processed, expected); 662 + } 663 + 664 + #[test] 665 + fn test_markdown_integration_html_blocks_with_shortcodes() { 666 + let shortcodes = create_test_shortcodes(); 667 + let content = r#"<div class="custom"> 668 + <h2>{{ greet name=HTML }}</h2> 669 + <p>{{ simple }}</p> 670 + </div> 671 + 672 + <img src="test.jpg" alt="{{ greet name=Alt }}" title="{{ date format=attr }}">"#; 673 + let processed = with_test_route_context(|route_ctx| { 674 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 675 + }) 676 + .unwrap(); 677 + let expected = r#"<div class="custom"> 678 + <h2>Hello, HTML!</h2> 679 + <p>SIMPLE_OUTPUT</p> 680 + </div> 681 + 682 + <img src="test.jpg" alt="Hello, Alt!" title="DATE[attr]">"#; 683 + assert_eq!(processed, expected); 684 + } 685 + 686 + #[test] 687 + fn test_markdown_integration_math_blocks_with_shortcodes() { 688 + let shortcodes = create_test_shortcodes(); 689 + let content = r#"Inline math: ${{ simple }}$ 690 + 691 + Block math: 692 + $$ 693 + {{ greet name=Math }} 694 + $$ 695 + 696 + {{ greet name=Text }} with $x = {{ simple }}$ inline."#; 697 + let processed = with_test_route_context(|route_ctx| { 698 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 699 + }) 700 + .unwrap(); 701 + let expected = r#"Inline math: $SIMPLE_OUTPUT$ 702 + 703 + Block math: 704 + $$ 705 + Hello, Math! 706 + $$ 707 + 708 + Hello, Text! with $x = SIMPLE_OUTPUT$ inline."#; 709 + assert_eq!(processed, expected); 710 + } 711 + 712 + #[test] 713 + fn test_markdown_integration_frontmatter_yaml_with_shortcodes() { 714 + let shortcodes = create_test_shortcodes(); 715 + let content = r#"--- 716 + title: "{{ greet name=Blog }}" 717 + description: {{ simple }} 718 + tags: 719 + - {{ greet name=Tag1 }} 720 + - {{ simple }} 721 + metadata: 722 + created: {{ date format=iso }} 723 + author: {{ greet name=Author }} 724 + --- 725 + 726 + # {{ greet name=Content }} 727 + 728 + {{ simple }}"#; 729 + let processed = with_test_route_context(|route_ctx| { 730 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 731 + }) 732 + .unwrap(); 733 + let expected = r#"--- 734 + title: "Hello, Blog!" 735 + description: SIMPLE_OUTPUT 736 + tags: 737 + - Hello, Tag1! 738 + - SIMPLE_OUTPUT 739 + metadata: 740 + created: DATE[iso] 741 + author: Hello, Author! 742 + --- 743 + 744 + # Hello, Content! 745 + 746 + SIMPLE_OUTPUT"#; 747 + assert_eq!(processed, expected); 748 + } 749 + 750 + #[test] 751 + fn test_markdown_integration_block_shortcodes_with_markdown() { 752 + let shortcodes = create_test_shortcodes(); 753 + let content = r#"{{ section title=Main }} 754 + # {{ greet name=Header }} 755 + 756 + **{{ greet name=Bold }}** and *{{ simple }}* 757 + 758 + - {{ greet name=Item1 }} 759 + - {{ simple }} 760 + 761 + > {{ greet name=Quote }} 762 + 763 + {{ /section }}"#; 764 + let processed = with_test_route_context(|route_ctx| { 765 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 766 + }) 767 + .unwrap(); 768 + let expected = r#"<section title="Main"> 769 + # Hello, Header! 770 + 771 + **Hello, Bold!** and *SIMPLE_OUTPUT* 772 + 773 + - Hello, Item1! 774 + - SIMPLE_OUTPUT 775 + 776 + > Hello, Quote! 777 + 778 + </section>"#; 779 + assert_eq!(processed, expected); 780 + } 781 + 782 + #[test] 783 + fn test_markdown_integration_edge_cases() { 784 + let shortcodes = create_test_shortcodes(); 785 + let content = r#"{{ greet name=Start }} 786 + 787 + <!-- {{ simple }} in comment --> 788 + 789 + {{ highlight lang=markdown }} 790 + # {{ greet name=NestedMD }} 791 + {{ simple }} 792 + {{ /highlight }} 793 + 794 + `{{ greet name=BacktickCode }}` 795 + 796 + {{ greet name=End }}"#; 797 + let processed = with_test_route_context(|route_ctx| { 798 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 799 + }) 800 + .unwrap(); 801 + let expected = r#"Hello, Start! 802 + 803 + <!-- SIMPLE_OUTPUT in comment --> 804 + 805 + <code lang="markdown"> 806 + # Hello, NestedMD! 807 + SIMPLE_OUTPUT 808 + </code> 809 + 810 + `Hello, BacktickCode!` 811 + 812 + Hello, End!"#; 813 + assert_eq!(processed, expected); 814 + } 815 + 816 + #[test] 817 + fn test_markdown_integration_real_world_blog_post() { 818 + let shortcodes = create_test_shortcodes(); 819 + let content = r#"--- 820 + title: {{ greet name=BlogPost }} 821 + date: {{ date format=iso }} 822 + author: {{ greet name=Writer }} 823 + tags: [{{ simple }}, {{ greet name=Tutorial }}] 824 + --- 825 + 826 + # {{ greet name=Reader }}! 827 + 828 + Welcome to my blog post about {{ simple }}. 829 + 830 + ## What we'll cover 831 + 832 + 1. **{{ greet name=Introduction }}** - Getting started 833 + 2. **{{ simple }}** basics 834 + 3. Advanced {{ greet name=Techniques }} 835 + 836 + {{ alert type=info }} 837 + 💡 **Tip**: {{ greet name=Remember }} to {{ simple }}! 838 + {{ /alert }} 839 + 840 + ## Code Example 841 + 842 + {{ highlight lang=rust }} 843 + fn main() { 844 + println!("{{ greet name=World }}!"); 845 + // {{ simple }} 846 + } 847 + {{ /highlight }} 848 + 849 + ### Task List 850 + 851 + - [x] {{ greet name=Setup }} 852 + - [ ] {{ simple }} 853 + - [ ] {{ greet name=Publish }} 854 + 855 + --- 856 + 857 + > "{{ greet name=Quote }}" - {{ simple }} 858 + 859 + {{ section title=Resources }} 860 + - [Documentation](https://docs.rs) - {{ simple }} 861 + - [GitHub](https://github.com) - {{ greet name=Source }} 862 + {{ /section }} 863 + 864 + *Published on {{ date format=long }}*"#; 865 + 866 + let processed = with_test_route_context(|route_ctx| { 867 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 868 + }) 869 + .unwrap(); 870 + let expected = r#"--- 871 + title: Hello, BlogPost! 872 + date: DATE[iso] 873 + author: Hello, Writer! 874 + tags: [SIMPLE_OUTPUT, Hello, Tutorial!] 875 + --- 876 + 877 + # Hello, Reader!! 878 + 879 + Welcome to my blog post about SIMPLE_OUTPUT. 880 + 881 + ## What we'll cover 882 + 883 + 1. **Hello, Introduction!** - Getting started 884 + 2. **SIMPLE_OUTPUT** basics 885 + 3. Advanced Hello, Techniques! 886 + 887 + <div class="alert alert-info"> 888 + 💡 **Tip**: Hello, Remember! to SIMPLE_OUTPUT! 889 + </div> 890 + 891 + ## Code Example 892 + 893 + <code lang="rust"> 894 + fn main() { 895 + println!("Hello, World!!"); 896 + // SIMPLE_OUTPUT 897 + } 898 + </code> 899 + 900 + ### Task List 901 + 902 + - [x] Hello, Setup! 903 + - [ ] SIMPLE_OUTPUT 904 + - [ ] Hello, Publish! 905 + 906 + --- 907 + 908 + > "Hello, Quote!" - SIMPLE_OUTPUT 909 + 910 + <section title="Resources"> 911 + - [Documentation](https://docs.rs) - SIMPLE_OUTPUT 912 + - [GitHub](https://github.com) - Hello, Source! 913 + </section> 914 + 915 + *Published on DATE[long]*"#; 916 + assert_eq!(processed, expected); 917 + } 918 + 919 + #[test] 920 + fn test_invalid_shortcode_names() { 921 + let shortcodes = create_test_shortcodes(); 922 + 923 + // Test invalid names that should be treated as literal text 924 + let test_cases = vec![ 925 + ("{{ 123invalid }}", "{{ 123invalid }}"), // starts with number 926 + ("{{ -invalid }}", "{{ -invalid }}"), // starts with dash 927 + ("{{ invalid-name }}", "{{ invalid-name }}"), // contains dash 928 + ("{{ invalid.name }}", "{{ invalid.name }}"), // contains dot 929 + ("{{ invalid@name }}", "{{ invalid@name }}"), // contains special char 930 + ]; 931 + 932 + for (input, expected) in test_cases { 933 + let result = preprocess_shortcodes_simple(input, &shortcodes).unwrap(); 934 + assert_eq!(result, expected, "Failed for input: {}", input); 935 + } 936 + } 937 + 938 + #[test] 939 + fn test_valid_shortcode_names() { 940 + let mut shortcodes = create_test_shortcodes(); 941 + 942 + // Add shortcodes with valid names 943 + shortcodes.register("valid_name", |_, _| "VALID_NAME".to_string()); 944 + shortcodes.register("ValidName", |_, _| "VALID_NAME_CAMEL".to_string()); 945 + shortcodes.register("_underscore", |_, _| "UNDERSCORE".to_string()); 946 + shortcodes.register("name123", |_, _| "NAME123".to_string()); 947 + 948 + let test_cases = vec![ 949 + ("{{ valid_name }}", "VALID_NAME"), 950 + ("{{ ValidName }}", "VALID_NAME_CAMEL"), 951 + ("{{ _underscore }}", "UNDERSCORE"), 952 + ("{{ name123 }}", "NAME123"), 953 + ("{{ a }}", "{{ a }}"), // single char is invalid (too short for pattern) 954 + ]; 955 + 956 + for (input, expected) in test_cases { 957 + let result = preprocess_shortcodes_simple(input, &shortcodes).unwrap(); 958 + assert_eq!(result, expected, "Failed for input: {}", input); 959 + } 960 + } 961 + 962 + #[test] 963 + fn test_escaped_shortcode_syntax() { 964 + let shortcodes = create_test_shortcodes(); 965 + 966 + // Test cases where we encounter \{{ 967 + let test_cases = vec![ 968 + (r#"\{{"hello"}}"#, r#"\{{"hello"}}"#), 969 + (r#"Before \{{test}} after"#, r#"Before \{{test}} after"#), 970 + ( 971 + r#"\{{invalid}} and {{ simple }}"#, 972 + r#"\{{invalid}} and SIMPLE_OUTPUT"#, 973 + ), 974 + ]; 975 + 976 + for (input, expected) in test_cases { 977 + let result = preprocess_shortcodes_simple(input, &shortcodes).unwrap(); 978 + assert_eq!(result, expected, "Failed for input: {}", input); 979 + } 980 + } 981 + 982 + #[test] 983 + fn test_shortcode_name_validation_edge_cases() { 984 + let shortcodes = create_test_shortcodes(); 985 + 986 + // Test various edge cases - invalid names should be treated as literal text 987 + let test_cases = vec![ 988 + ("{{ 1 }}", "{{ 1 }}"), // single digit (invalid) 989 + ("{{ _ }}", "{{ _ }}"), // single underscore (invalid - too short) 990 + ("{{ 1A }}", "{{ 1A }}"), // invalid: digit + letter 991 + ("{{ café }}", "{{ café }}"), // invalid: non-ASCII 992 + ]; 993 + 994 + for (input, expected) in test_cases { 995 + let result = preprocess_shortcodes_simple(input, &shortcodes).unwrap(); 996 + assert_eq!(result, expected, "Failed for input: {}", input); 997 + } 998 + 999 + // Test valid names that aren't registered - should get "Unknown shortcode" error 1000 + let valid_but_unregistered = 1001 + vec!["{{ _a }}", "{{ a_ }}", "{{ A1 }}", "{{ valid_name123 }}"]; 1002 + 1003 + for input in valid_but_unregistered { 1004 + let result = preprocess_shortcodes_simple(input, &shortcodes); 1005 + assert!( 1006 + result.is_err(), 1007 + "Should error for unregistered shortcode: {}", 1008 + input 1009 + ); 1010 + assert!( 1011 + result.unwrap_err().contains("Unknown shortcode"), 1012 + "Wrong error type for: {}", 1013 + input 1014 + ); 1015 + } 1016 + 1017 + // Test completely empty shortcode separately since it causes an error 1018 + let empty_result = preprocess_shortcodes_simple("{{ }}", &shortcodes); 1019 + assert!(empty_result.is_err()); 1020 + assert!(empty_result.unwrap_err().contains("Empty shortcode")); 1021 + 1022 + // Test whitespace-only shortcode 1023 + let whitespace_result = preprocess_shortcodes_simple("{{ }}", &shortcodes); 1024 + assert!(whitespace_result.is_err()); 1025 + assert!(whitespace_result.unwrap_err().contains("Empty shortcode")); 1026 + } 1027 + 1028 + #[test] 1029 + fn test_mixed_valid_invalid_shortcodes() { 1030 + let shortcodes = create_test_shortcodes(); 1031 + 1032 + let content = r#" 1033 + Valid: {{ simple }} 1034 + Invalid number start: {{ 123invalid }} 1035 + Valid underscore: {{ greet name=Test }} 1036 + Invalid dash: {{ invalid-name }} 1037 + Another valid: {{ date format=iso }} 1038 + "#; 1039 + 1040 + let result = with_test_route_context(|route_ctx| { 1041 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 1042 + }) 1043 + .unwrap(); 1044 + let expected = r#" 1045 + Valid: SIMPLE_OUTPUT 1046 + Invalid number start: {{ 123invalid }} 1047 + Valid underscore: Hello, Test! 1048 + Invalid dash: {{ invalid-name }} 1049 + Another valid: DATE[iso] 1050 + "#; 1051 + 1052 + assert_eq!(result, expected); 1053 + } 1054 + 1055 + #[test] 1056 + fn test_quoted_parameter_values() { 1057 + let shortcodes = create_test_shortcodes(); 1058 + 1059 + // Test double quotes 1060 + let content = r#"{{ greet name="Hello World" }}"#; 1061 + let result = with_test_route_context(|route_ctx| { 1062 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 1063 + }) 1064 + .unwrap(); 1065 + assert_eq!(result, "Hello, Hello World!"); 1066 + 1067 + // Test single quotes 1068 + let content = r#"{{ greet name='Hello World' }}"#; 1069 + let result = with_test_route_context(|route_ctx| { 1070 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 1071 + }) 1072 + .unwrap(); 1073 + assert_eq!(result, "Hello, Hello World!"); 1074 + } 1075 + 1076 + #[test] 1077 + fn test_mixed_quoted_unquoted_parameters() { 1078 + let mut shortcodes = create_test_shortcodes(); 1079 + shortcodes.register("message", |args, _| { 1080 + let text = args.get_str("text").unwrap_or(""); 1081 + let author = args.get_str("author").unwrap_or("Anonymous"); 1082 + format!("{} - {}", text, author) 1083 + }); 1084 + 1085 + let content = r#"{{ message text="Hello World" author=John }}"#; 1086 + let result = with_test_route_context(|route_ctx| { 1087 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 1088 + }) 1089 + .unwrap(); 1090 + assert_eq!(result, "Hello World - John"); 1091 + } 1092 + 1093 + #[test] 1094 + fn test_quotes_with_special_characters() { 1095 + let mut shortcodes = create_test_shortcodes(); 1096 + shortcodes.register("special", |args, _| { 1097 + args.get_str("value").unwrap_or("").to_string() 1098 + }); 1099 + 1100 + let content = r#"{{ special value="Hello, World! How are you?" }}"#; 1101 + let result = with_test_route_context(|route_ctx| { 1102 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 1103 + }) 1104 + .unwrap(); 1105 + assert_eq!(result, "Hello, World! How are you?"); 1106 + } 1107 + 1108 + #[test] 1109 + fn test_error_unclosed_quotes() { 1110 + let shortcodes = create_test_shortcodes(); 1111 + 1112 + let content = r#"{{ greet name="Hello World }}"#; 1113 + let result = with_test_route_context(|route_ctx| { 1114 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 1115 + }); 1116 + assert!(result.is_err()); 1117 + assert!(result.unwrap_err().contains("Unclosed quote")); 1118 + } 1119 + 1120 + #[test] 1121 + fn test_empty_quoted_values() { 1122 + let mut shortcodes = create_test_shortcodes(); 1123 + shortcodes.register("empty", |args, _| { 1124 + let value = args.get_str("value").unwrap_or("default"); 1125 + format!("'{}'", value) 1126 + }); 1127 + 1128 + let content = r#"{{ empty value="" }}"#; 1129 + let result = with_test_route_context(|route_ctx| { 1130 + preprocess_shortcodes_with_ctx(content, &shortcodes, route_ctx) 1131 + }) 1132 + .unwrap(); 1133 + assert_eq!(result, "''"); 1134 + } 1135 + }
+1 -1
examples/markdown-components/src/main.rs
··· 31 31 .table(CustomTable) 32 32 .table_head(CustomTableHead) 33 33 .table_row(CustomTableRow) 34 - .table_cell(CustomTableCell) 34 + .table_cell(CustomTableCell), Default::default() 35 35 ) 36 36 )) 37 37 ],