1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
use rustc_hash::FxHashMap;
use std::str::FromStr;
use crate::route::PageContext;
pub type ShortcodeFn =
Box<dyn Fn(&ShortcodeArgs, Option<&mut PageContext>) -> String + Send + Sync>;
#[derive(Default)]
pub struct MarkdownShortcodes(FxHashMap<String, ShortcodeFn>);
impl MarkdownShortcodes {
pub fn new() -> Self {
Self(FxHashMap::default())
}
pub fn register<F>(&mut self, name: &str, func: F)
where
F: Fn(&ShortcodeArgs, Option<&mut PageContext>) -> String + Send + Sync + 'static,
{
self.0.insert(name.to_string(), Box::new(func));
}
pub(crate) fn get(&self, name: &str) -> Option<&ShortcodeFn> {
self.0.get(name)
}
pub(crate) fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
// Helper function to validate shortcode names
// Valid names match ^[A-Za-z_][0-9A-Za-z_]+$ pattern
fn is_valid_shortcode_name(name: &str) -> bool {
if name.len() < 2 {
return false; // Must have at least 2 characters
}
let mut chars = name.chars();
// First character must be A-Z, a-z, or _
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return false;
}
// Remaining characters must be A-Z, a-z, 0-9, or _
for ch in chars {
if !ch.is_ascii_alphanumeric() && ch != '_' {
return false;
}
}
true
}
pub fn preprocess_shortcodes(
content: &str,
shortcodes: &MarkdownShortcodes,
mut route_ctx: Option<&mut PageContext>,
markdown_path: Option<&str>,
) -> Result<String, String> {
let mut output = String::new();
let mut rest = content;
// TODO: Rewrite all of this or at least review it carefully, it's a mess and it was generated by AI
while let Some(start) = rest.find("{{") {
// Check for escaped shortcode syntax like `\{{` - if found, skip this occurrence
if start > 0 && rest.chars().nth(start - 1) == Some('\\') {
// Remove the backslash and output the literal {{
output.push_str(&rest[..start - 1]); // up to the backslash
output.push_str("{{"); // output {{
rest = &rest[start + 2..];
continue;
}
// Add everything before the shortcode
output.push_str(&rest[..start]);
// Find the end of the opening shortcode tag
let remaining = &rest[start + 2..];
let Some(tag_end) = remaining.find("}}") else {
// No closing }}, treat as literal text
output.push_str("{{");
rest = remaining;
continue;
};
let shortcode_content = remaining[..tag_end].trim();
// Check if this is a self-closing shortcode (ends with /)
let is_self_closing = shortcode_content.ends_with('/');
let shortcode_content = if is_self_closing {
shortcode_content.trim_end_matches('/').trim()
} else {
shortcode_content
};
// Parse shortcode name and arguments
let mut parts = shortcode_content.split_whitespace();
let name = parts.next().ok_or("Empty shortcode")?;
// Check if this is a closing tag
if name.starts_with('/') {
return Err(format!("Unexpected closing tag: {}", name));
}
// Validate shortcode name format
let actual_name = name.strip_prefix('/').unwrap_or(name);
if !is_valid_shortcode_name(actual_name) {
// Invalid shortcode name, treat as literal text and continue
output.push_str("{{");
rest = remaining;
continue;
}
// Parse arguments with support for quoted values
let mut args = FxHashMap::default();
let args_str = parts.collect::<Vec<_>>().join(" ");
if !args_str.is_empty() {
let mut chars = args_str.chars().peekable();
let mut current_key = String::new();
let mut current_value = String::new();
let mut in_key = true;
let mut in_quotes = false;
let mut quote_char = ' ';
while let Some(ch) = chars.next() {
match ch {
'=' if in_key && !in_quotes => {
in_key = false;
// Check if next char is a quote
if let Some(&next_ch) = chars.peek()
&& (next_ch == '"' || next_ch == '\'')
{
quote_char = next_ch;
in_quotes = true;
chars.next(); // consume the quote
}
}
'\\' if !in_key && in_quotes => {
// Handle escaped characters
if let Some(escaped_ch) = chars.next() {
match escaped_ch {
'"' | '\'' => {
// Escaped quote - add literal quote character
current_value.push(escaped_ch);
}
'\\' => {
// Escaped backslash - add literal backslash
current_value.push('\\');
}
'n' => {
// Escaped newline
current_value.push('\n');
}
't' => {
// Escaped tab
current_value.push('\t');
}
'r' => {
// Escaped carriage return
current_value.push('\r');
}
_ => {
// For any other escaped character, keep the backslash and the character
current_value.push('\\');
current_value.push(escaped_ch);
}
}
} else {
// Trailing backslash - add it literally
current_value.push('\\');
}
}
'"' | '\'' if !in_key && in_quotes && ch == quote_char => {
// End of quoted value
in_quotes = false;
args.insert(current_key.trim().to_string(), current_value.clone());
current_key.clear();
current_value.clear();
in_key = true;
// Skip any whitespace after the closing quote
while let Some(&next_ch) = chars.peek() {
if next_ch.is_whitespace() {
chars.next();
} else {
break;
}
}
}
' ' if !in_quotes => {
if !in_key && !current_value.is_empty() {
// End of unquoted value
args.insert(
current_key.trim().to_string(),
current_value.trim().to_string(),
);
current_key.clear();
current_value.clear();
in_key = true;
} else if in_key && !current_key.is_empty() {
return Err(format!(
"Invalid argument format: '{}'. Expected 'key=value'",
current_key
));
}
// Skip multiple spaces
while let Some(&next_ch) = chars.peek() {
if next_ch == ' ' {
chars.next();
} else {
break;
}
}
}
_ => {
if in_key {
current_key.push(ch);
} else {
current_value.push(ch);
}
}
}
}
// Handle the last argument if there's one pending
if !in_key && (!current_value.is_empty() || !in_quotes) {
if in_quotes {
return Err("Unclosed quote in argument value".to_string());
}
args.insert(
current_key.trim().to_string(),
current_value.trim().to_string(),
);
} else if !current_key.trim().is_empty() {
return Err(format!(
"Invalid argument format: '{}'. Expected 'key=value'",
current_key.trim()
));
}
}
// Move past the opening tag
let after_opening_tag = &remaining[tag_end + 2..];
if is_self_closing {
// Self-closing shortcode - execute immediately
if let Some(func) = shortcodes.get(name) {
let mut shortcode_args = ShortcodeArgs::new(args);
shortcode_args.0.insert(
"markdown_path".to_string(),
markdown_path.unwrap_or("").to_string(),
);
let result = func(&shortcode_args, route_ctx.as_deref_mut());
output.push_str(&result);
} else {
return Err(format!("Unknown shortcode: '{}'", name));
}
// Continue after the opening tag
rest = after_opening_tag;
} else {
// Block shortcode - look for closing tag
let closing_tag_compact = format!("{{{{/{}}}}}", name);
let closing_tag_spaced = format!("{{{{ /{} }}}}", name);
let close_pos = after_opening_tag
.find(&closing_tag_compact)
.or_else(|| after_opening_tag.find(&closing_tag_spaced));
if let Some(close_pos) = close_pos {
// Determine which closing tag format was found to calculate the correct length
let closing_tag_len =
if after_opening_tag[close_pos..].starts_with(&closing_tag_compact) {
closing_tag_compact.len()
} else {
closing_tag_spaced.len()
};
// Block shortcode - extract body and recursively process it
let body = &after_opening_tag[..close_pos];
let processed_body = preprocess_shortcodes(
body,
shortcodes,
route_ctx.as_deref_mut(),
markdown_path,
)?;
// Execute shortcode with processed body
if let Some(func) = shortcodes.get(name) {
let mut shortcode_args = ShortcodeArgs::new(args);
shortcode_args.0.insert("body".to_string(), processed_body);
shortcode_args.0.insert(
"markdown_path".to_string(),
markdown_path.unwrap_or("").to_string(),
);
let result = func(&shortcode_args, route_ctx.as_deref_mut());
output.push_str(&result);
} else {
return Err(format!("Unknown shortcode: '{}'", name));
}
// Continue after the closing tag
rest = &after_opening_tag[close_pos + closing_tag_len..];
} else {
// No closing tag found for block shortcode - this is an error
return Err(format!(
"Block shortcode '{}' is missing its closing tag. Use '{{{{ {} /}}}}' for self-closing shortcodes or add '{{{{/{}}}}}'",
name, name, name
));
}
}
}
output.push_str(rest);
Ok(output)
}
pub struct ShortcodeArgs(FxHashMap<String, String>);
impl ShortcodeArgs {
pub fn new(args: FxHashMap<String, String>) -> Self {
Self(args)
}
/// Get argument with automatic type conversion
pub fn get<T>(&self, key: &str) -> Option<T>
where
T: FromStr,
T::Err: std::fmt::Debug,
{
self.0.get(key)?.parse().ok()
}
/// Get required argument with automatic type conversion
pub fn get_required<T>(&self, key: &str) -> T
where
T: FromStr,
T::Err: std::fmt::Debug,
{
self.0
.get(key)
.unwrap_or_else(|| panic!("Required argument '{}' not found", key))
.parse()
.unwrap_or_else(|e| panic!("Failed to parse argument '{}': {:?}", key, e))
}
/// Get argument with default value and type conversion
pub fn get_or<T>(&self, key: &str, default: T) -> T
where
T: FromStr,
T::Err: std::fmt::Debug,
{
self.0
.get(key)
.and_then(|s| s.parse().ok())
.unwrap_or(default)
}
/// Get raw string (no conversion)
pub fn get_str(&self, key: &str) -> Option<&str> {
self.0.get(key).map(|s| s.as_str())
}
pub fn get_str_required(&self, key: &str) -> &str {
self.0
.get(key)
.map(|s| s.as_str())
.unwrap_or_else(|| panic!("Required argument '{}' not found", key))
}
pub fn get_str_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
self.0.get(key).map(|s| s.as_str()).unwrap_or(default)
}
}
// Macro to make typed shortcodes easier to write
#[macro_export]
macro_rules! shortcode {
($args:ident, $($param:ident: $type:ty),* => $body:expr) => {
|$args: &ShortcodeArgs| -> String {
$(
let $param: $type = $args.get_required(stringify!($param));
)*
$body
}
};
($args:ident, $($param:ident: $type:ty = $default:expr),* => $body:expr) => {
|$args: &ShortcodeArgs| -> String {
$(
let $param: $type = $args.get_or(stringify!($param), $default);
)*
$body
}
};
}