1use crate::error::{PathErrorExt, SsgError};
10use crate::plugin::{Plugin, PluginContext};
11use crate::MAX_DIR_DEPTH;
12use std::{
13 collections::HashMap,
14 fs,
15 path::{Path, PathBuf},
16};
17
18#[derive(Debug, Clone, Copy)]
32pub struct ShortcodePlugin;
33
34impl Plugin for ShortcodePlugin {
35 fn name(&self) -> &'static str {
36 "shortcodes"
37 }
38
39 fn before_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
40 if !ctx.content_dir.exists() {
41 return Ok(());
42 }
43
44 let md_files = collect_md_files(&ctx.content_dir)?;
45 let mut expanded = 0usize;
46
47 for path in &md_files {
48 let content = fs::read_to_string(path).with_path(path)?;
49 let result = expand_shortcodes(&content);
50 if result != content {
51 fs::write(path, &result).with_path(path)?;
52 expanded += 1;
53 }
54 }
55
56 if expanded > 0 {
57 log::info!(
58 "[shortcodes] Expanded shortcodes in {expanded} file(s)"
59 );
60 }
61 Ok(())
62 }
63}
64
65#[must_use]
76pub fn expand_shortcodes(input: &str) -> String {
77 let mut result = input.to_string();
78
79 for name in &["warning", "info", "tip", "danger"] {
81 result = expand_block_shortcode(&result, name);
82 }
83
84 result = expand_inline_shortcodes(&result);
86
87 result
88}
89
90fn expand_block_shortcode(input: &str, name: &str) -> String {
92 let open = format!("{{{{< {name} >}}}}");
93 let close = format!("{{{{< /{name} >}}}}");
94 let mut result = input.to_string();
95
96 while let Some(start) = result.find(&open) {
97 let after_open = start + open.len();
98 if let Some(end_offset) = result[after_open..].find(&close) {
99 let end = after_open + end_offset;
100 let inner = result[after_open..end].trim();
101 let html = format!(
102 "<div class=\"admonition admonition-{}\" role=\"note\">\n\
103 <p class=\"admonition-title\">{}</p>\n\
104 <div class=\"admonition-content\">\n{}\n</div>\n</div>",
105 name,
106 capitalize(name),
107 inner
108 );
109 result = format!(
110 "{}{}{}",
111 &result[..start],
112 html,
113 &result[end + close.len()..]
114 );
115 } else {
116 break;
117 }
118 }
119
120 result
121}
122
123fn expand_inline_shortcodes(input: &str) -> String {
130 let mut result = String::with_capacity(input.len());
131 let mut pos = 0;
132
133 while pos < input.len() {
134 if input.is_char_boundary(pos)
138 && pos + 3 <= input.len()
139 && input.as_bytes()[pos] == b'{'
140 && input.as_bytes()[pos + 1] == b'{'
141 && input.as_bytes()[pos + 2] == b'<'
142 {
143 if let Some(end) = input[pos..].find(">}}") {
144 let tag = input[pos + 3..pos + end].trim();
145 let html = render_inline_shortcode(tag);
146 result.push_str(&html);
147 pos += end + 3;
148 continue;
149 }
150 }
151 #[allow(clippy::expect_used)]
157 let c = input[pos..]
158 .chars()
159 .next()
160 .expect("loop guard ensures pos < input.len()");
161 result.push(c);
162 pos += c.len_utf8();
163 }
164
165 result
166}
167
168fn render_inline_shortcode(tag: &str) -> String {
170 let parts = parse_shortcode_attrs(tag);
171 let name = parts.get("_name").map_or("", String::as_str);
172
173 match name {
174 "youtube" => {
175 let id = parts.get("id").map_or("", String::as_str);
176 if id.is_empty() {
177 return "<!-- youtube: missing id -->".to_string();
178 }
179 format!(
180 "<div class=\"video-container\" style=\"position:relative;padding-bottom:56.25%;height:0;overflow:hidden\">\
181 <iframe src=\"https://www.youtube-nocookie.com/embed/{id}\" \
182 style=\"position:absolute;top:0;left:0;width:100%;height:100%\" \
183 frameborder=\"0\" allowfullscreen loading=\"lazy\" \
184 title=\"YouTube video\"></iframe></div>"
185 )
186 }
187 "gist" => {
188 let user = parts.get("user").map_or("", String::as_str);
189 let id = parts.get("id").map_or("", String::as_str);
190 if user.is_empty() || id.is_empty() {
191 return "<!-- gist: missing user or id -->".to_string();
192 }
193 format!(
194 "<script src=\"https://gist.github.com/{user}/{id}.js\"></script>"
195 )
196 }
197 "figure" => {
198 let src = parts.get("src").map_or("", String::as_str);
199 let alt = parts.get("alt").map_or("", String::as_str);
200 let caption = parts.get("caption").map_or("", String::as_str);
201 let mut html = format!(
202 "<figure><img src=\"{src}\" alt=\"{alt}\" loading=\"lazy\">"
203 );
204 if !caption.is_empty() {
205 html.push_str(&format!("<figcaption>{caption}</figcaption>"));
206 }
207 html.push_str("</figure>");
208 html
209 }
210 "island" => {
211 let component = parts.get("component").map_or("", String::as_str);
212 let hydrate =
213 parts.get("hydrate").map_or("visible", String::as_str);
214 let props = parts.get("props").map_or("{}", String::as_str);
215 if component.is_empty() {
216 return "<!-- island: missing component -->".to_string();
217 }
218 format!(
219 "<ssg-island component=\"{component}\" hydrate=\"{hydrate}\" props='{props}'>\
220 <template shadowrootmode=\"open\"><slot></slot></template>\
221 </ssg-island>"
222 )
223 }
224 _ => format!("<!-- unknown shortcode: {name} -->"),
225 }
226}
227
228fn parse_shortcode_attrs(tag: &str) -> HashMap<String, String> {
230 let mut attrs = HashMap::new();
231 let trimmed = tag.trim();
232
233 let mut chars = trimmed.char_indices().peekable();
235 let mut name_end = 0;
236 while let Some(&(i, c)) = chars.peek() {
237 if c.is_whitespace() {
238 name_end = i;
239 break;
240 }
241 name_end = i + c.len_utf8();
242 let _ = chars.next();
243 }
244 let _ = attrs.insert("_name".to_string(), trimmed[..name_end].to_string());
245
246 let rest = &trimmed[name_end..];
248 let mut pos = 0;
249 while pos < rest.len() {
250 while pos < rest.len() && rest.as_bytes()[pos].is_ascii_whitespace() {
252 pos += 1;
253 }
254 if pos >= rest.len() {
255 break;
256 }
257
258 let key_start = pos;
260 while pos < rest.len() && rest.as_bytes()[pos] != b'=' {
261 pos += 1;
262 }
263 if pos >= rest.len() {
264 break;
265 }
266 let key = rest[key_start..pos].trim().to_string();
267 pos += 1; if pos < rest.len() && rest.as_bytes()[pos] == b'"' {
271 pos += 1;
272 let val_start = pos;
273 while pos < rest.len() && rest.as_bytes()[pos] != b'"' {
274 pos += 1;
275 }
276 let val = rest[val_start..pos].to_string();
277 let _ = attrs.insert(key, val);
278 pos += 1; }
280 }
281
282 attrs
283}
284
285fn capitalize(s: &str) -> String {
286 let mut c = s.chars();
287 match c.next() {
288 None => String::new(),
289 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
290 }
291}
292
293fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
294 crate::walk::walk_files_bounded_depth(dir, "md", MAX_DIR_DEPTH)
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_youtube_shortcode() {
303 let input = r#"Check this: {{< youtube id="abc123" >}}"#;
304 let result = expand_shortcodes(input);
305 assert!(result.contains("youtube-nocookie.com/embed/abc123"));
306 assert!(result.contains("video-container"));
307 }
308
309 #[test]
310 fn test_gist_shortcode() {
311 let input = r#"{{< gist user="octocat" id="12345" >}}"#;
312 let result = expand_shortcodes(input);
313 assert!(result.contains("gist.github.com/octocat/12345.js"));
314 }
315
316 #[test]
317 fn test_figure_shortcode() {
318 let input = r#"{{< figure src="/img/photo.jpg" alt="A photo" caption="My photo" >}}"#;
319 let result = expand_shortcodes(input);
320 assert!(result.contains("<figure>"));
321 assert!(result.contains("alt=\"A photo\""));
322 assert!(result.contains("<figcaption>My photo</figcaption>"));
323 }
324
325 #[test]
326 fn test_warning_block() {
327 let input = "{{< warning >}}\nBe careful!\n{{< /warning >}}";
328 let result = expand_shortcodes(input);
329 assert!(result.contains("admonition-warning"));
330 assert!(result.contains("Warning"));
331 assert!(result.contains("Be careful!"));
332 }
333
334 #[test]
335 fn test_info_block() {
336 let input = "{{< info >}}\nNote this.\n{{< /info >}}";
337 let result = expand_shortcodes(input);
338 assert!(result.contains("admonition-info"));
339 assert!(result.contains("Info"));
340 }
341
342 #[test]
343 fn test_unknown_shortcode() {
344 let input = r#"{{< unknown key="val" >}}"#;
345 let result = expand_shortcodes(input);
346 assert!(result.contains("<!-- unknown shortcode: unknown -->"));
347 }
348
349 #[test]
350 fn test_no_shortcodes() {
351 let input = "Regular markdown with no shortcodes.";
352 let result = expand_shortcodes(input);
353 assert_eq!(result, input);
354 }
355
356 #[test]
357 fn test_parse_attrs() {
358 let attrs = parse_shortcode_attrs(r#"youtube id="abc" "#);
359 assert_eq!(attrs.get("_name").unwrap(), "youtube");
360 assert_eq!(attrs.get("id").unwrap(), "abc");
361 }
362
363 #[test]
368 fn name_returns_static_shortcodes_identifier() {
369 assert_eq!(ShortcodePlugin.name(), "shortcodes");
370 }
371
372 #[test]
373 fn shortcode_plugin_debug_output_names_the_type() {
374 assert_eq!(format!("{:?}", ShortcodePlugin), "ShortcodePlugin");
378 }
379
380 #[test]
381 fn before_compile_missing_content_dir_returns_ok() {
382 let dir = tempfile::tempdir().unwrap();
384 let missing = dir.path().join("missing");
385 let ctx =
386 PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
387 ShortcodePlugin.before_compile(&ctx).unwrap();
388 }
389
390 #[test]
391 fn before_compile_no_markdown_files_is_noop() {
392 let dir = tempfile::tempdir().unwrap();
393 let content = dir.path().join("content");
394 fs::create_dir_all(&content).unwrap();
395 let ctx =
396 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
397 ShortcodePlugin.before_compile(&ctx).unwrap();
398 }
399
400 #[test]
401 fn before_compile_unchanged_file_is_not_rewritten() {
402 let dir = tempfile::tempdir().unwrap();
405 let content = dir.path().join("content");
406 fs::create_dir_all(&content).unwrap();
407 let body = "plain markdown no shortcodes";
408 fs::write(content.join("p.md"), body).unwrap();
409 let ctx =
410 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
411 ShortcodePlugin.before_compile(&ctx).unwrap();
412 assert_eq!(fs::read_to_string(content.join("p.md")).unwrap(), body);
413 }
414
415 #[test]
420 fn render_inline_shortcode_youtube_missing_id_emits_comment() {
421 let result = expand_shortcodes(r"{{< youtube >}}");
423 assert!(result.contains("<!-- youtube: missing id -->"));
424 }
425
426 #[test]
427 fn render_inline_shortcode_gist_missing_user_emits_comment() {
428 let result = expand_shortcodes(r#"{{< gist id="123" >}}"#);
430 assert!(result.contains("<!-- gist: missing user or id -->"));
431 }
432
433 #[test]
434 fn render_inline_shortcode_gist_missing_id_emits_comment() {
435 let result = expand_shortcodes(r#"{{< gist user="octocat" >}}"#);
436 assert!(result.contains("<!-- gist: missing user or id -->"));
437 }
438
439 #[test]
440 fn render_inline_shortcode_figure_without_caption_omits_figcaption() {
441 let result =
444 expand_shortcodes(r#"{{< figure src="/a.jpg" alt="A" >}}"#);
445 assert!(result.contains("<figure>"));
446 assert!(result.contains(r#"alt="A""#));
447 assert!(!result.contains("<figcaption>"));
448 }
449
450 #[test]
451 fn render_inline_shortcode_figure_with_caption_includes_figcaption() {
452 let result = expand_shortcodes(
453 r#"{{< figure src="/a.jpg" alt="A" caption="Hi" >}}"#,
454 );
455 assert!(result.contains("<figcaption>Hi</figcaption>"));
456 }
457
458 #[test]
463 fn expand_block_shortcode_unterminated_breaks_out_cleanly() {
464 let input = "{{< warning >}}\nno closing tag\n";
471 let result = expand_block_shortcode(input, "warning");
472 assert_eq!(result, input);
475 }
476
477 #[test]
482 fn capitalize_empty_string_returns_empty() {
483 assert_eq!(capitalize(""), "");
484 }
485
486 #[test]
487 fn capitalize_single_word_uppercases_first_letter() {
488 assert_eq!(capitalize("warning"), "Warning");
489 assert_eq!(capitalize("info"), "Info");
490 }
491
492 #[test]
497 fn shortcodes_collect_md_files_respects_max_dir_depth() {
498 let dir = tempfile::tempdir().unwrap();
499 let mut current = dir.path().to_path_buf();
500 for i in 0..MAX_DIR_DEPTH + 2 {
501 current = current.join(format!("d{i}"));
502 fs::create_dir_all(¤t).unwrap();
503 fs::write(current.join("p.md"), "").unwrap();
504 }
505 let files = collect_md_files(dir.path()).unwrap();
506 assert!(files.len() <= MAX_DIR_DEPTH + 1);
507 }
508
509 #[test]
510 fn parse_shortcode_attrs_trailing_whitespace_breaks_outer_loop() {
511 let attrs = parse_shortcode_attrs("name ");
515 assert_eq!(attrs.get("_name").unwrap(), "name");
516 assert_eq!(attrs.len(), 1);
517 }
518
519 #[test]
520 fn parse_shortcode_attrs_with_value_then_trailing_whitespace() {
521 let attrs = parse_shortcode_attrs("youtube id=\"x\" ");
522 assert_eq!(attrs.get("_name").unwrap(), "youtube");
523 assert_eq!(attrs.get("id").unwrap(), "x");
524 }
525
526 #[test]
527 fn parse_shortcode_attrs_key_without_equals_breaks() {
528 let attrs = parse_shortcode_attrs("youtube id=\"x\" trailingflag");
531 assert_eq!(attrs.get("_name").unwrap(), "youtube");
532 assert!(!attrs.contains_key("trailingflag"));
534 }
535
536 #[test]
537 fn parse_shortcode_attrs_unquoted_value_is_dropped() {
538 let attrs = parse_shortcode_attrs("name id=unquoted");
541 assert_eq!(attrs.get("_name").unwrap(), "name");
542 assert!(!attrs.contains_key("id"));
543 }
544
545 #[test]
546 fn expand_inline_shortcodes_unterminated_tag_falls_through_to_pushchar() {
547 let result = expand_shortcodes("text {{< unterminated");
551 assert!(result.contains("text"));
552 assert!(result.contains("unterminated"));
553 }
554
555 #[test]
556 fn shortcodes_collect_md_files_filters_non_md_extensions() {
557 let dir = tempfile::tempdir().unwrap();
558 fs::write(dir.path().join("a.md"), "").unwrap();
559 fs::write(dir.path().join("b.txt"), "").unwrap();
560 let files = collect_md_files(dir.path()).unwrap();
561 assert_eq!(files.len(), 1);
562 }
563
564 #[test]
565 fn test_plugin_expands_files() {
566 let dir = tempfile::tempdir().unwrap();
567 let content = dir.path().join("content");
568 fs::create_dir_all(&content).unwrap();
569 fs::write(
570 content.join("test.md"),
571 r#"---
572title: Test
573---
574{{< youtube id="xyz" >}}
575"#,
576 )
577 .unwrap();
578
579 let ctx =
580 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
581 ShortcodePlugin.before_compile(&ctx).unwrap();
582
583 let result = fs::read_to_string(content.join("test.md")).unwrap();
584 assert!(result.contains("youtube-nocookie.com"));
585 }
586
587 #[test]
588 fn render_inline_shortcode_island_missing_component_emits_comment() {
589 let result = expand_shortcodes(r"{{< island >}}");
591 assert!(result.contains("<!-- island: missing component -->"));
592 }
593
594 #[test]
595 #[cfg(unix)]
596 fn before_compile_propagates_walk_error_from_unreadable_subdir() {
597 use std::os::unix::fs::PermissionsExt;
598
599 let dir = tempfile::tempdir().unwrap();
600 let content = dir.path().join("content");
601 let locked = content.join("locked");
602 fs::create_dir_all(&locked).unwrap();
603 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
604 .unwrap();
605
606 let ctx =
607 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
608 let result = ShortcodePlugin.before_compile(&ctx);
609 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
610 .unwrap();
611 assert!(result.is_err(), "unreadable subdir must surface as Err");
612 }
613
614 #[test]
615 #[cfg(unix)]
616 fn before_compile_write_failure_on_readonly_file_is_propagated() {
617 use std::os::unix::fs::PermissionsExt;
618
619 let dir = tempfile::tempdir().unwrap();
620 let content = dir.path().join("content");
621 fs::create_dir_all(&content).unwrap();
622 let file = content.join("locked.md");
623 fs::write(&file, r#"{{< youtube id="abc" >}}"#).unwrap();
624 fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap();
626
627 let ctx =
628 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
629 let result = ShortcodePlugin.before_compile(&ctx);
630 fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
631 assert!(result.is_err(), "read-only file write must surface as Err");
632 }
633
634 #[test]
635 fn before_compile_read_failure_returns_io_error() {
636 let dir = tempfile::tempdir().unwrap();
637 let content = dir.path().join("content");
638 fs::create_dir_all(&content).unwrap();
639
640 let file_path = content.join("fail.md");
642 fs::write(&file_path, [0xFF, 0xFE, 0xFD]).unwrap();
643
644 let ctx =
645 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
646 let res = ShortcodePlugin.before_compile(&ctx);
647 assert!(res.is_err());
648 let err = res.unwrap_err();
649 assert!(
650 matches!(err, SsgError::Io { ref path, .. } if path == &file_path)
651 );
652 }
653}
654
655#[cfg(test)]
656mod proptests {
657 use super::*;
658 use proptest::prelude::*;
659
660 proptest! {
661 #![proptest_config(ProptestConfig::with_cases(1000))]
662
663 #[test]
665 fn expand_never_panics(input in "\\PC*") {
666 let _ = expand_shortcodes(&input);
667 }
668
669 #[test]
671 fn no_shortcode_identity(input in "[^{]*") {
672 let output = expand_shortcodes(&input);
673 prop_assert_eq!(&output, &input);
674 }
675 }
676}