1use super::attrs::gather_multi_line_attrs;
22use super::cells::split_cells;
23use super::node::Block;
24use super::shortcode::{
25 ButtonItem, ButtonsShortcode, GalleryItem, GalleryShortcode, GridShortcode, HeroShortcode,
26 RecentShortcode, Shortcode, SubscribeShortcode,
27};
28use super::url::Url;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ExtractedShortcode {
33 pub index: usize,
35 pub shortcode: Shortcode,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ExtractionResult {
42 pub markdown_with_placeholders: String,
45 pub extracted: Vec<ExtractedShortcode>,
47 pub nonce: String,
55 pub warnings: Vec<String>,
59}
60
61const TYPED_KNOWN: &[&str] = &["subscribe", "buttons", "gallery", "hero", "grid", "recent"];
65
66fn is_typed_known(name: &str) -> bool {
67 TYPED_KNOWN.contains(&name)
68}
69
70fn parse_shortcode_block(name: &str, args: &str, body: &str) -> (Option<Shortcode>, Vec<String>) {
79 match name {
80 "subscribe" => (Some(Shortcode::Subscribe(parse_subscribe_args(args))), vec![]),
81 "buttons" => (Some(Shortcode::Buttons(parse_buttons_body(args, body))), vec![]),
82 "gallery" => (Some(Shortcode::Gallery(parse_gallery_body(args, body))), vec![]),
83 "hero" => {
84 let (sc, used_p3) = parse_hero(args, body);
85 let mut warns = vec![];
86 if used_p3 {
87 warns.push(
88 "shortcode `:::hero` uses a body-image fallback (deprecated Priority 3). \
89 Move the image path to the `image=` attribute: \
90 `:::hero {image=path.jpg}`."
91 .to_string(),
92 );
93 }
94 (Some(Shortcode::Hero(sc)), warns)
95 }
96 "grid" => {
97 let (sc, legacy) = parse_grid(args, body);
98 let mut warns = vec![];
99 if legacy {
100 warns.push(
101 "shortcode `:::grid` uses `---` cell dividers (deprecated). Migrate to `+++`.\n\
102 `---` support will be removed in a future release."
103 .to_string(),
104 );
105 }
106 (Some(Shortcode::Grid(sc)), warns)
107 }
108 "recent" => (Some(Shortcode::Recent(parse_recent_args(args, body))), vec![]),
109 _ => (None, vec![]),
110 }
111}
112
113pub fn parse_recent_args(args: &str, body: &str) -> RecentShortcode {
126 let attrs = super::attrs::parse_attrs(args).unwrap_or_default();
127 RecentShortcode {
128 since: attrs.get("since").map(str::to_string),
129 last: attrs.get("last").map(str::to_string),
130 count: attrs.get("count").and_then(|v| v.parse::<u32>().ok()),
131 fallback_markdown: body.trim().to_string(),
132 }
133}
134
135fn parse_grid(args: &str, body: &str) -> (GridShortcode, bool) {
152 let trimmed = args.trim();
153 let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed.find('{') {
154 #[allow(clippy::string_slice)]
156 (trimmed[..pos].trim(), &trimmed[pos..])
157 } else {
158 (trimmed, "")
159 };
160
161 let parsed = if attr_block.is_empty() {
162 Default::default()
163 } else {
164 super::attrs::parse_attrs(attr_block).unwrap_or_default()
165 };
166 let classes = parsed.class_string();
167 let width = parsed.width.map(str::to_string);
168
169 let mut columns: u32 = 1;
170 let mut ratio: Option<String> = None;
171
172 if let Some(cols_value) = parsed.get("cols") {
173 if cols_value.contains(':') {
174 ratio = Some(cols_value.to_string());
175 columns = cols_value.split(':').count() as u32;
176 } else if let Ok(n) = cols_value.parse::<u32>() {
177 columns = n.max(1);
178 }
179 } else {
180 let parts: Vec<&str> = positional.split_whitespace().collect();
182 if let Some(first) = parts.first() {
183 if first.contains(':') {
184 ratio = Some(first.to_string());
185 columns = first.split(':').count() as u32;
186 } else if let Ok(n) = first.parse::<u32>() {
187 columns = n.max(1);
188 if let Some(second) = parts.get(1) {
189 if second.contains(':') {
190 ratio = Some(second.to_string());
191 }
192 }
193 }
194 }
195 }
196
197 let (raw_cells, found_legacy_dash) = split_grid_cells(body);
198
199 let cells: Vec<Vec<Block>> = raw_cells
214 .iter()
215 .map(|raw| parse_cell_to_blocks(raw))
216 .collect();
217
218 (
219 GridShortcode {
220 columns,
221 ratio,
222 classes,
223 cells,
224 width,
225 },
226 found_legacy_dash,
227 )
228}
229
230fn parse_cell_to_blocks(raw: &str) -> Vec<Block> {
238 if let Some((url, inner)) = detect_compound_link(raw) {
239 let inner_trimmed = inner.trim();
240 let inner_is_plain_text = !inner_trimmed.contains('!')
256 && !inner_trimmed.contains('[')
257 && !inner_trimmed.contains('\n');
258 let is_external = url.starts_with("http://") || url.starts_with("https://");
259 if inner_is_plain_text && is_external {
260 let linkified = format!("[{}]({})", inner_trimmed, url);
263 return super::parser::parse(&linkified).blocks;
264 }
265 let inner_doc = super::parser::parse(inner_trimmed);
266 return vec![Block::LinkCard {
267 url: Url::unresolved(url),
268 children: inner_doc.blocks,
269 }];
270 }
271 if let Some(url) = detect_bare_url_cell(raw) {
284 let linkified = format!("[]({})", url);
285 let doc = super::parser::parse(&linkified);
286 return doc.blocks;
287 }
288 let doc = super::parser::parse(raw);
289 doc.blocks
290}
291
292fn detect_bare_url_cell(cell_text: &str) -> Option<String> {
300 let trimmed = cell_text.trim();
301 if trimmed.is_empty() {
302 return None;
303 }
304 if trimmed.lines().count() > 1 {
305 return None;
306 }
307 if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
308 return None;
309 }
310 if trimmed.chars().any(char::is_whitespace) {
311 return None;
312 }
313 Some(trimmed.to_string())
314}
315
316pub(super) fn detect_compound_link(cell_text: &str) -> Option<(String, String)> {
340 let stripped = cell_text.trim();
341
342 if !stripped.starts_with('[') {
343 return None;
344 }
345 if !stripped.ends_with(')') {
346 return None;
347 }
348 if stripped.len() > 1 && stripped.as_bytes()[1] == b'`' {
349 return None;
350 }
351
352 for line in stripped.lines() {
353 let t = line.trim();
354 if t.starts_with("```") || t.starts_with("~~~") {
355 return None;
356 }
357 }
358
359 let bytes = stripped.as_bytes();
360
361 let mut i: usize = 1;
363 let mut depth: usize = 1;
364 let mut outer_close: Option<usize> = None;
365
366 while i < bytes.len() {
367 match bytes[i] {
368 b'\\' => {
369 i += 2;
370 continue;
371 }
372 b'`' => {
373 let tick_start = i;
374 while i < bytes.len() && bytes[i] == b'`' {
375 i += 1;
376 }
377 let fence_len = i - tick_start;
378 'code_scan: while i < bytes.len() {
379 if bytes[i] == b'`' {
380 let close_start = i;
381 while i < bytes.len() && bytes[i] == b'`' {
382 i += 1;
383 }
384 if i - close_start == fence_len {
385 break 'code_scan;
386 }
387 } else {
388 i += 1;
389 }
390 }
391 continue;
392 }
393 b'[' => {
394 depth += 1;
395 }
396 b']' => {
397 depth -= 1;
398 if depth == 0 {
399 outer_close = Some(i);
400 break;
401 }
402 }
403 _ => {}
404 }
405 i += 1;
406 }
407
408 let close_bracket = outer_close?;
409
410 if bytes.get(close_bracket + 1) != Some(&b'(') {
411 return None;
412 }
413
414 let mut j = close_bracket + 2;
416 let mut pdepth: usize = 1;
417 let mut paren_close: Option<usize> = None;
418
419 while j < bytes.len() {
420 match bytes[j] {
421 b'\\' => {
422 j += 2;
423 continue;
424 }
425 b'(' => pdepth += 1,
426 b')' => {
427 pdepth -= 1;
428 if pdepth == 0 {
429 paren_close = Some(j);
430 break;
431 }
432 }
433 _ => {}
434 }
435 j += 1;
436 }
437
438 let close_paren = paren_close?;
439
440 let tail = &stripped[close_paren + 1..];
442 if !tail.chars().all(|c| c.is_whitespace()) {
443 return None;
444 }
445
446 let inner = &stripped[1..close_bracket];
448 if inner.trim().is_empty() {
449 return None;
450 }
451
452 {
454 let inner_bytes = inner.as_bytes();
455 let mut k: usize = 0;
456 let mut image_stack: Vec<bool> = Vec::new();
457
458 while k < inner_bytes.len() {
459 match inner_bytes[k] {
460 b'\\' => {
461 k += 2;
462 continue;
463 }
464 b'`' => {
465 let tick_start = k;
466 while k < inner_bytes.len() && inner_bytes[k] == b'`' {
467 k += 1;
468 }
469 let fence_len = k - tick_start;
470 'inner_code: while k < inner_bytes.len() {
471 if inner_bytes[k] == b'`' {
472 let cs = k;
473 while k < inner_bytes.len() && inner_bytes[k] == b'`' {
474 k += 1;
475 }
476 if k - cs == fence_len {
477 break 'inner_code;
478 }
479 } else {
480 k += 1;
481 }
482 }
483 continue;
484 }
485 b'[' => {
486 let preceded_by_bang = k > 0 && inner_bytes[k - 1] == b'!';
487 image_stack.push(preceded_by_bang);
488 }
489 b']' => {
490 if let Some(is_image) = image_stack.pop() {
491 if image_stack.is_empty() && inner_bytes.get(k + 1) == Some(&b'(') {
492 if !is_image {
493 return None;
494 }
495 }
496 }
497 }
498 _ => {}
499 }
500 k += 1;
501 }
502 }
503
504 let url = &stripped[close_bracket + 2..close_paren];
505 Some((url.to_string(), inner.to_string()))
506}
507
508fn split_grid_cells(body: &str) -> (Vec<String>, bool) {
519 if body.is_empty() {
520 return (vec![String::new()], false);
521 }
522 let mut cells = Vec::new();
523 let mut current = String::new();
524 let mut first_line_in_cell = true;
525 let mut found_legacy_dash = false;
526
527 for line in body.split_inclusive('\n') {
528 let content_no_eol = line.strip_suffix('\n').unwrap_or(line);
529 let trimmed = content_no_eol.trim();
530 if trimmed == "+++" || trimmed == "---" {
531 if trimmed == "---" {
532 found_legacy_dash = true;
533 }
534 if let Some(stripped) = current.strip_suffix('\n') {
535 current.truncate(stripped.len());
536 }
537 cells.push(std::mem::take(&mut current));
538 first_line_in_cell = true;
539 continue;
540 }
541 if first_line_in_cell {
542 first_line_in_cell = false;
543 if trimmed.is_empty() {
544 continue;
545 }
546 }
547 current.push_str(line);
548 }
549 if let Some(stripped) = current.strip_suffix('\n') {
550 current.truncate(stripped.len());
551 }
552 cells.push(current);
553 (cells, found_legacy_dash)
554}
555
556fn parse_hero(args: &str, body: &str) -> (HeroShortcode, bool) {
574 let trimmed_args = args.trim();
575
576 let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed_args.find('{') {
579 #[allow(clippy::string_slice)]
581 (trimmed_args[..pos].trim(), &trimmed_args[pos..])
582 } else {
583 (trimmed_args, "")
584 };
585
586 let parsed = if attr_block.is_empty() {
588 Default::default()
589 } else {
590 super::attrs::parse_attrs(attr_block).unwrap_or_default()
591 };
592 let classes = parsed.class_string();
593 let width = parsed.width.map(str::to_string);
594
595 if let Some(image_value) = parsed.get("image") {
597 let (path, attrs_str) = crate::media::split_pipe(image_value);
598 let overlay_text = body.trim().to_string();
599 let overlay = parse_overlay_to_blocks(&overlay_text);
600 return (
601 HeroShortcode {
602 image: if path.trim().is_empty() {
603 None
604 } else {
605 Some(Url::unresolved(path.trim().to_string()))
606 },
607 attrs: attrs_str.to_string(),
608 classes,
609 overlay,
610 overlay_text,
611 width,
612 },
613 false,
614 );
615 }
616
617 if !positional.is_empty() {
621 let (path, attrs_str) = crate::media::split_pipe(positional);
622 let overlay_text = body.trim().to_string();
623 let overlay = parse_overlay_to_blocks(&overlay_text);
624 return (
625 HeroShortcode {
626 image: if path.trim().is_empty() {
627 None
628 } else {
629 Some(Url::unresolved(path.trim().to_string()))
630 },
631 attrs: attrs_str.to_string(),
632 classes,
633 overlay,
634 overlay_text,
635 width,
636 },
637 false,
638 );
639 }
640
641 let mut overlay_lines: Vec<&str> = Vec::new();
643 let mut image_path: Option<String> = None;
644 let mut image_attrs = String::new();
645 let mut found_image = false;
646 let mut used_priority_3 = false;
647 for line in body.lines() {
648 if !found_image && !line.trim().is_empty() {
649 if let Some((path, attrs_str)) = parse_hero_media_line(line) {
650 image_path = Some(path);
651 image_attrs = attrs_str;
652 found_image = true;
653 used_priority_3 = true;
654 continue;
655 }
656 found_image = true;
658 }
659 overlay_lines.push(line);
660 }
661 let overlay_text = overlay_lines.join("\n").trim().to_string();
662 let overlay = parse_overlay_to_blocks(&overlay_text);
663 (
664 HeroShortcode {
665 image: image_path.map(Url::unresolved),
666 attrs: image_attrs,
667 classes,
668 overlay,
669 overlay_text,
670 width,
671 },
672 used_priority_3,
673 )
674}
675
676fn parse_overlay_to_blocks(raw: &str) -> Vec<Block> {
683 if raw.is_empty() {
684 return Vec::new();
685 }
686 let doc = super::parser::parse(raw);
687 doc.blocks
688}
689
690const HERO_MEDIA_EXTENSIONS: &[&str] = &[
692 "jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov",
693];
694
695fn is_bare_hero_media(s: &str) -> bool {
696 let (path_part, _) = crate::media::split_pipe(s);
697 let path = path_part.trim();
698 path.rfind('.')
699 .map(|dot| {
700 #[allow(clippy::string_slice)]
703 let ext = &path[dot + 1..];
704 HERO_MEDIA_EXTENSIONS
705 .iter()
706 .any(|e| e.eq_ignore_ascii_case(ext))
707 })
708 .unwrap_or(false)
709}
710
711fn parse_hero_media_line(line: &str) -> Option<(String, String)> {
713 let trimmed = line.trim();
714
715 if let Some(inner) = trimmed
717 .strip_prefix("![[")
718 .and_then(|s| s.strip_suffix("]]"))
719 {
720 let (path, attrs_str) = crate::media::split_pipe(inner);
721 return Some((path.trim().to_string(), attrs_str.to_string()));
722 }
723
724 if trimmed.starts_with(" {
727 if trimmed.ends_with(')') {
728 #[allow(clippy::string_slice)]
732 let inner = &trimmed[paren_open + 2..trimmed.len() - 1];
733 let (path, attrs_str) = crate::media::split_pipe(inner);
734 return Some((path.trim().to_string(), attrs_str.to_string()));
735 }
736 }
737 }
738
739 if is_bare_hero_media(trimmed) {
741 let (path, attrs_str) = crate::media::split_pipe(trimmed);
742 return Some((path.trim().to_string(), attrs_str.to_string()));
743 }
744
745 None
746}
747
748fn parse_gallery_body(args: &str, body: &str) -> GalleryShortcode {
749 let (positional, classes, width) = split_positional_classes_and_width(args);
753 let columns = if positional.is_empty() {
754 None
755 } else {
756 positional.parse::<u32>().ok()
757 };
758 let mut items: Vec<GalleryItem> = Vec::new();
759 for line in body.lines() {
760 let trimmed = line.trim();
761 if trimmed.is_empty() {
762 continue;
763 }
764 let (src_raw, attrs) = split_pipe(trimmed);
767 let (src_url, alt) = match parse_markdown_image(src_raw) {
768 Some((alt, path)) => (path, alt),
769 None => (src_raw.trim().to_string(), String::new()),
770 };
771 items.push(GalleryItem {
772 src: Url::unresolved(src_url),
773 alt,
774 attrs: attrs.to_string(),
775 });
776 }
777 GalleryShortcode {
778 columns,
779 classes,
780 items,
781 width,
782 }
783}
784
785fn split_positional_classes_and_width(args: &str) -> (String, String, Option<String>) {
793 let trimmed = args.trim();
794 if let Some(brace_start) = trimmed.find('{') {
795 #[allow(clippy::string_slice)]
796 let after_open = &trimmed[brace_start..];
797 if let Some(brace_end) = after_open.find('}') {
798 #[allow(clippy::string_slice)]
799 let positional = trimmed[..brace_start].trim().to_string();
800 #[allow(clippy::string_slice)]
801 let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
802 if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
803 return (
804 positional,
805 parsed.class_string(),
806 parsed.width.map(str::to_string),
807 );
808 }
809 #[allow(clippy::string_slice)]
814 let inner = &trimmed[brace_start + 1..brace_start + brace_end];
815 let mut classes = Vec::new();
816 for token in inner.split_whitespace() {
817 if let Some(class) = token.strip_prefix('.') {
818 if !class.is_empty() {
819 classes.push(class);
820 }
821 }
822 }
823 return (positional, classes.join(" "), None);
824 }
825 }
826 (trimmed.to_string(), String::new(), None)
827}
828
829fn split_positional_and_classes(args: &str) -> (String, String) {
843 let trimmed = args.trim();
844 if let Some(brace_start) = trimmed.find('{') {
845 #[allow(clippy::string_slice)]
849 let after_open = &trimmed[brace_start..];
850 if let Some(brace_end) = after_open.find('}') {
851 #[allow(clippy::string_slice)]
855 let positional = trimmed[..brace_start].trim().to_string();
856 #[allow(clippy::string_slice)]
857 let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
858 if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
859 return (positional, parsed.class_string());
860 }
861 #[allow(clippy::string_slice)]
864 let inner = &trimmed[brace_start + 1..brace_start + brace_end];
865 let mut classes = Vec::new();
866 for token in inner.split_whitespace() {
867 if let Some(class) = token.strip_prefix('.') {
868 if !class.is_empty() {
869 classes.push(class);
870 }
871 }
872 }
873 return (positional, classes.join(" "));
874 }
875 }
876 (trimmed.to_string(), String::new())
877}
878
879fn split_pipe(s: &str) -> (&str, &str) {
881 match s.split_once('|') {
882 Some((before, after)) => (before, after.trim()),
883 None => (s, ""),
884 }
885}
886
887fn parse_markdown_image(s: &str) -> Option<(String, String)> {
890 let s = s.trim();
891 let rest = s.strip_prefix("?;
893 let close_paren = after.rfind(')')?;
894 #[allow(clippy::string_slice)]
896 let path = &after[..close_paren];
897 if path.contains('(') {
898 return None;
899 }
900 Some((alt.to_string(), path.to_string()))
901}
902
903fn parse_buttons_body(args: &str, body: &str) -> ButtonsShortcode {
904 let (_positional, classes) = split_positional_and_classes(args);
905 let mut items: Vec<ButtonItem> = Vec::new();
906 for cell in split_cells(body) {
911 for line in cell.lines() {
912 let trimmed = line.trim();
913 if trimmed.is_empty() {
914 continue;
915 }
916 if let Some((text, url)) = extract_markdown_link(trimmed) {
917 items.push(ButtonItem {
918 text,
919 url: Url::unresolved(url),
920 });
921 }
922 }
924 }
925 ButtonsShortcode { classes, items }
926}
927
928fn extract_markdown_link(s: &str) -> Option<(String, String)> {
931 let s = s.trim();
932 let inside = s.strip_prefix('[')?;
933 let (text, after) = inside.split_once(']')?;
934 let url = after.strip_prefix('(').and_then(|r| r.strip_suffix(')'))?;
935 if url.is_empty() {
936 return None;
937 }
938 Some((text.to_string(), url.to_string()))
939}
940
941fn parse_subscribe_args(args: &str) -> SubscribeShortcode {
948 let parsed = match super::attrs::parse_attrs(args) {
951 Ok(b) => b,
952 Err(_) => return SubscribeShortcode::default(),
953 };
954 let placeholder = parsed
955 .get("placeholder")
956 .filter(|s| !s.is_empty())
957 .map(str::to_string);
958 let button = parsed
959 .get("button")
960 .filter(|s| !s.is_empty())
961 .map(str::to_string);
962 SubscribeShortcode {
963 placeholder,
964 button,
965 }
966}
967
968pub fn placeholder_for(nonce: &str, index: usize) -> String {
976 format!("<!--MOSS_SC_{nonce}_{index}-->")
977}
978
979pub fn parse_placeholder(nonce: &str, html: &str) -> Option<usize> {
985 let trim = html.trim();
986 let prefix = format!("<!--MOSS_SC_{nonce}_");
987 let inner = trim.strip_prefix(&prefix)?;
988 let inner = inner.strip_suffix("-->")?;
989 inner.parse::<usize>().ok()
990}
991
992fn compute_nonce(input: &str) -> String {
997 use std::hash::{Hash, Hasher};
998 let mut hasher = std::collections::hash_map::DefaultHasher::new();
999 input.hash(&mut hasher);
1000 let h = hasher.finish() as u32;
1006 format!("{h:08x}")
1007}
1008
1009pub fn extract_shortcodes(markdown: &str) -> ExtractionResult {
1017 let nonce = compute_nonce(markdown);
1018 let mut extracted: Vec<ExtractedShortcode> = Vec::new();
1019 let mut warnings: Vec<String> = Vec::new();
1020 let output = extract_with_state(markdown, &nonce, &mut extracted, &mut warnings);
1021 ExtractionResult {
1022 markdown_with_placeholders: output,
1023 extracted,
1024 nonce,
1025 warnings,
1026 }
1027}
1028
1029fn extract_with_state(
1036 markdown: &str,
1037 nonce: &str,
1038 extracted: &mut Vec<ExtractedShortcode>,
1039 warnings: &mut Vec<String>,
1040) -> String {
1041 let mut output = String::with_capacity(markdown.len());
1042 let lines: Vec<&str> = markdown.lines().collect();
1043 let mut i = 0;
1044 let mut in_code_fence = false;
1045 let mut fence_marker = String::new();
1046
1047 while i < lines.len() {
1048 let line = lines[i];
1049 let trimmed = line.trim();
1050
1051 if in_code_fence {
1053 output.push_str(line);
1054 output.push('\n');
1055 let fence_char = fence_marker.chars().next().unwrap_or(' ');
1063 if trimmed.starts_with(&fence_marker)
1064 && trimmed.trim_start_matches(fence_char).trim().is_empty()
1065 {
1066 in_code_fence = false;
1067 fence_marker.clear();
1068 }
1069 i += 1;
1070 continue;
1071 }
1072 if let Some(marker) = detect_code_fence_open(trimmed) {
1073 in_code_fence = true;
1074 fence_marker = marker;
1075 output.push_str(line);
1076 output.push('\n');
1077 i += 1;
1078 continue;
1079 }
1080
1081 if let Some((arity, name, single_line_args)) = parse_shortcode_opener(trimmed) {
1083 let (args_owned, opener_lines_consumed) =
1092 gather_multi_line_attrs(single_line_args, &lines[i + 1..]);
1093 let args: &str = args_owned.as_deref().unwrap_or(single_line_args);
1094 let body_start = i + 1 + opener_lines_consumed;
1095
1096 let mut body_lines: Vec<&str> = Vec::new();
1098 let mut j = body_start;
1099 let mut closed = false;
1100 while j < lines.len() {
1101 if is_close_fence(lines[j].trim(), arity) {
1102 closed = true;
1103 break;
1104 }
1105 body_lines.push(lines[j]);
1106 j += 1;
1107 }
1108
1109 if !closed {
1110 output.push_str(line);
1113 output.push('\n');
1114 i += 1;
1115 continue;
1116 }
1117
1118 let body = body_lines.join("\n");
1119
1120 if name.is_empty() {
1136 let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1144 let body_processed = extract_with_state(&body, nonce, extracted, warnings);
1145 output.push_str(&render_div_open(&parsed.classes, parsed.id.as_deref(), None));
1146 output.push_str("\n\n");
1147 output.push_str(&body_processed);
1148 if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1149 output.push('\n');
1150 }
1151 output.push_str("\n</div>\n");
1152 i = j + 1;
1153 continue;
1154 }
1155
1156 if is_typed_known(name) {
1157 if let (Some(sc), parse_warnings) = parse_shortcode_block(name, args, &body) {
1158 warnings.extend(parse_warnings);
1159 let index = extracted.len();
1160 output.push_str(&placeholder_for(&nonce, index));
1161 output.push('\n');
1162 extracted.push(ExtractedShortcode {
1163 index,
1164 shortcode: sc,
1165 });
1166 i = j + 1;
1167 continue;
1168 }
1169 output.push_str(line);
1173 output.push('\n');
1174 i += 1;
1175 continue;
1176 }
1177
1178 let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1183 warnings.push(format!("unknown shortcode `:::{}`", name));
1184 let mut classes = vec!["moss-unknown-shortcode".to_string()];
1185 classes.extend(parsed.classes.iter().cloned());
1186 let extra_attrs = format!(r#" data-name="{}""#, html_escape_attr(name));
1187 let body_processed = extract_with_state(&body, nonce, extracted, warnings);
1188 output.push_str(&render_div_open(&classes, parsed.id.as_deref(), Some(&extra_attrs)));
1189 output.push_str("\n\n");
1190 output.push_str(&body_processed);
1191 if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1192 output.push('\n');
1193 }
1194 output.push_str("\n</div>\n");
1195 i = j + 1;
1196 continue;
1197 }
1198
1199 output.push_str(line);
1201 output.push('\n');
1202 i += 1;
1203 }
1204
1205 output
1206}
1207
1208fn render_div_open(classes: &[String], id: Option<&str>, extra_attrs: Option<&str>) -> String {
1213 let mut out = String::from("<div");
1214 if !classes.is_empty() {
1215 out.push_str(" class=\"");
1216 for (i, c) in classes.iter().enumerate() {
1217 if i > 0 {
1218 out.push(' ');
1219 }
1220 out.push_str(&html_escape_attr(c));
1221 }
1222 out.push('"');
1223 }
1224 if let Some(id_val) = id {
1225 out.push_str(" id=\"");
1226 out.push_str(&html_escape_attr(id_val));
1227 out.push('"');
1228 }
1229 if let Some(extra) = extra_attrs {
1230 out.push_str(extra);
1231 }
1232 out.push('>');
1233 out
1234}
1235
1236fn html_escape_attr(s: &str) -> String {
1239 let mut out = String::with_capacity(s.len());
1240 for c in s.chars() {
1241 match c {
1242 '&' => out.push_str("&"),
1243 '<' => out.push_str("<"),
1244 '>' => out.push_str(">"),
1245 '"' => out.push_str("""),
1246 '\'' => out.push_str("'"),
1247 _ => out.push(c),
1248 }
1249 }
1250 out
1251}
1252
1253fn detect_code_fence_open(trimmed: &str) -> Option<String> {
1254 if trimmed.starts_with("```") {
1255 Some("```".to_string())
1256 } else if trimmed.starts_with("~~~") {
1257 Some("~~~".to_string())
1258 } else {
1259 None
1260 }
1261}
1262
1263fn parse_shortcode_opener(trimmed: &str) -> Option<(usize, &str, &str)> {
1276 let colons = trimmed.chars().take_while(|&c| c == ':').count();
1277 if colons < 3 {
1278 return None;
1279 }
1280 #[allow(clippy::string_slice)]
1284 let rest = &trimmed[colons..];
1285 let name_end = rest
1287 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
1288 .unwrap_or(rest.len());
1289 if name_end == 0 {
1290 let after_ws = rest.trim_start();
1293 if !after_ws.starts_with('{') {
1294 return None;
1295 }
1296 return Some((colons, "", rest.trim()));
1297 }
1298 #[allow(clippy::string_slice)]
1301 let name = &rest[..name_end];
1302 #[allow(clippy::string_slice)]
1303 let args = rest[name_end..].trim();
1304 Some((colons, name, args))
1305}
1306
1307fn is_close_fence(trimmed: &str, arity: usize) -> bool {
1324 let mut chars = trimmed.chars();
1325 for _ in 0..arity {
1326 match chars.next() {
1327 Some(':') => {}
1328 _ => return false,
1329 }
1330 }
1331 chars.all(char::is_whitespace)
1333}
1334
1335#[cfg(test)]
1336mod tests {
1337 use super::*;
1338
1339 #[test]
1340 fn no_shortcodes_round_trips_input() {
1341 let md = "# Heading\n\npara with [link](u).\n";
1342 let result = extract_shortcodes(md);
1343 assert_eq!(result.markdown_with_placeholders, md);
1344 assert!(result.extracted.is_empty());
1345 }
1346
1347 #[test]
1348 fn extracts_subscribe_block_with_placeholder_and_button_attrs() {
1349 let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
1350:::
1351"#;
1352 let result = extract_shortcodes(md);
1353 assert_eq!(result.extracted.len(), 1);
1354 match &result.extracted[0].shortcode {
1355 Shortcode::Subscribe(args) => {
1356 assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
1357 assert_eq!(args.button.as_deref(), Some("Sign me up"));
1358 }
1359 other => panic!("expected Subscribe, got {other:?}"),
1360 }
1361 assert!(result
1362 .markdown_with_placeholders
1363 .contains(&placeholder_for(&result.nonce, 0)));
1364 assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
1365 }
1366
1367 #[test]
1368 fn extracts_subscribe_block_with_only_placeholder_attr() {
1369 let md = r#":::subscribe {placeholder="hi@example.com"}
1370:::
1371"#;
1372 let result = extract_shortcodes(md);
1373 match &result.extracted[0].shortcode {
1374 Shortcode::Subscribe(args) => {
1375 assert_eq!(args.placeholder.as_deref(), Some("hi@example.com"));
1376 assert!(args.button.is_none());
1377 }
1378 other => panic!("expected Subscribe, got {other:?}"),
1379 }
1380 }
1381
1382 #[test]
1383 fn extracts_subscribe_block_with_no_args() {
1384 let md = ":::subscribe\n:::\n";
1385 let result = extract_shortcodes(md);
1386 match &result.extracted[0].shortcode {
1387 Shortcode::Subscribe(args) => {
1388 assert!(args.placeholder.is_none());
1389 assert!(args.button.is_none());
1390 }
1391 other => panic!("expected Subscribe, got {other:?}"),
1392 }
1393 }
1394
1395 #[test]
1396 fn extracts_subscribe_block_with_multi_line_attrs() {
1397 let md = r#":::subscribe {
1398 placeholder="you@domain.com"
1399 button="Request access"
1400}
1401:::
1402"#;
1403 let result = extract_shortcodes(md);
1404 match &result.extracted[0].shortcode {
1405 Shortcode::Subscribe(args) => {
1406 assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
1407 assert_eq!(args.button.as_deref(), Some("Request access"));
1408 }
1409 other => panic!("expected Subscribe, got {other:?}"),
1410 }
1411 }
1412
1413 #[test]
1414 fn subscribe_legacy_body_keys_no_longer_parsed() {
1415 let md = ":::subscribe\ndescription: Get updates\n:::\n";
1419 let result = extract_shortcodes(md);
1420 match &result.extracted[0].shortcode {
1421 Shortcode::Subscribe(args) => {
1422 assert!(args.placeholder.is_none(), "old description body must not populate placeholder");
1423 assert!(args.button.is_none());
1424 }
1425 other => panic!("expected Subscribe, got {other:?}"),
1426 }
1427 }
1428
1429 #[test]
1430 fn subscribe_inside_code_fence_is_not_extracted() {
1431 let md = "```\n:::subscribe\ndescription: doc\n:::\n```\n";
1435 let result = extract_shortcodes(md);
1436 assert!(result.extracted.is_empty());
1437 assert!(result.markdown_with_placeholders.contains(":::subscribe"));
1438 }
1439
1440 #[test]
1441 fn subscribe_inside_tilde_fence_is_not_extracted() {
1442 let md = "~~~\n:::subscribe\n:::\n~~~\n";
1443 let result = extract_shortcodes(md);
1444 assert!(result.extracted.is_empty());
1445 }
1446
1447 #[test]
1448 fn unclosed_subscribe_block_emits_verbatim() {
1449 let md = ":::subscribe\nbutton: Go\n";
1451 let result = extract_shortcodes(md);
1452 assert!(result.extracted.is_empty());
1453 assert!(result.markdown_with_placeholders.contains(":::subscribe"));
1454 }
1455
1456 #[test]
1457 fn extracts_hero_block_with_body_image_typed() {
1458 let md = ":::hero\n![[bg.jpg]]\n:::\n";
1462 let result = extract_shortcodes(md);
1463 assert_eq!(result.extracted.len(), 1);
1464 match &result.extracted[0].shortcode {
1465 Shortcode::Hero(args) => match &args.image {
1466 Some(Url::Unresolved(s)) => assert_eq!(s, "bg.jpg"),
1467 _ => panic!("expected Unresolved bg.jpg"),
1468 },
1469 _ => panic!("expected Hero"),
1470 }
1471 assert!(!result.markdown_with_placeholders.contains(":::hero"));
1473 }
1474
1475 #[test]
1476 fn extracts_multiple_subscribes_with_increasing_indices() {
1477 let md = ":::subscribe\ndescription: a\n:::\n\nsome text\n\n:::subscribe\nbutton: b\n:::\n";
1478 let result = extract_shortcodes(md);
1479 assert_eq!(result.extracted.len(), 2);
1480 assert_eq!(result.extracted[0].index, 0);
1481 assert_eq!(result.extracted[1].index, 1);
1482 assert!(result
1483 .markdown_with_placeholders
1484 .contains(&placeholder_for(&result.nonce, 0)));
1485 assert!(result
1486 .markdown_with_placeholders
1487 .contains(&placeholder_for(&result.nonce, 1)));
1488 }
1489
1490 #[test]
1491 fn parse_placeholder_round_trips_index() {
1492 let nonce = "deadbeef";
1493 for index in [0, 1, 5, 99] {
1494 let s = placeholder_for(nonce, index);
1495 assert_eq!(parse_placeholder(nonce, &s), Some(index));
1496 }
1497 }
1498
1499 #[test]
1500 fn parse_placeholder_rejects_non_placeholder_html() {
1501 let nonce = "deadbeef";
1502 assert!(parse_placeholder(nonce, "<div>hi</div>").is_none());
1503 assert!(parse_placeholder(nonce, "<!--just a comment-->").is_none());
1504 }
1505
1506 #[test]
1507 fn parse_placeholder_rejects_wrong_nonce() {
1508 let s = placeholder_for("aaaa1111", 5);
1514 assert_eq!(parse_placeholder("bbbb2222", &s), None);
1515 }
1516
1517 #[test]
1518 fn extract_uses_content_derived_nonce() {
1519 let md = ":::subscribe\n:::\n";
1522 let r1 = extract_shortcodes(md);
1523 let r2 = extract_shortcodes(md);
1524 assert_eq!(r1.nonce, r2.nonce);
1525 let r3 = extract_shortcodes(":::subscribe\ndescription: x\n:::\n");
1528 assert_ne!(r1.nonce, r3.nonce);
1529 }
1530
1531 #[test]
1532 fn nonce_makes_authored_collision_inert() {
1533 let md = ":::subscribe\n:::\n\nLook: <!--MOSS_SC_00000000_0-->\n";
1537 let result = extract_shortcodes(md);
1538 assert_ne!(result.nonce, "00000000");
1541 assert!(result
1542 .markdown_with_placeholders
1543 .contains("MOSS_SC_00000000_0"));
1544 }
1545
1546 #[test]
1547 fn parse_shortcode_opener_recognizes_simple_name() {
1548 assert_eq!(
1549 parse_shortcode_opener(":::subscribe"),
1550 Some((3, "subscribe", ""))
1551 );
1552 }
1553
1554 #[test]
1555 fn parse_shortcode_opener_extracts_args() {
1556 assert_eq!(
1557 parse_shortcode_opener(":::grid 3 1:2:1"),
1558 Some((3, "grid", "3 1:2:1"))
1559 );
1560 }
1561
1562 #[test]
1563 fn parse_shortcode_opener_recognizes_quadruple_colon() {
1564 assert_eq!(
1567 parse_shortcode_opener("::::buttons"),
1568 Some((4, "buttons", ""))
1569 );
1570 }
1571
1572 #[test]
1573 fn parse_shortcode_opener_rejects_two_colons() {
1574 assert!(parse_shortcode_opener("::name").is_none());
1576 }
1577
1578 #[test]
1579 fn extracts_quadruple_colon_buttons() {
1580 let md = "::::buttons\n[Tickets](go/)\n::::\n";
1583 let result = extract_shortcodes(md);
1584 assert_eq!(result.extracted.len(), 1);
1585 match &result.extracted[0].shortcode {
1586 Shortcode::Buttons(args) => {
1587 assert_eq!(args.items.len(), 1);
1588 assert_eq!(args.items[0].text, "Tickets");
1589 }
1590 _ => panic!("expected Buttons"),
1591 }
1592 }
1593
1594 #[test]
1595 fn extracts_grid_with_nested_buttons_via_arity() {
1596 let md = ":::grid 2\n::::buttons\n[Tickets](go/)\n::::\n+++\nfooter cell\n:::\n";
1603 let result = extract_shortcodes(md);
1604 assert_eq!(result.extracted.len(), 1);
1605 match &result.extracted[0].shortcode {
1606 Shortcode::Grid(grid) => {
1607 assert_eq!(grid.columns, 2);
1608 assert_eq!(grid.cells.len(), 2);
1609 let has_typed_buttons = grid.cells[0].iter().any(|b| matches!(
1611 b,
1612 Block::Shortcode(Shortcode::Buttons(args)) if args.items.len() == 1
1613 && args.items[0].text == "Tickets"
1614 ));
1615 assert!(has_typed_buttons, "expected typed Buttons in cell[0]; got {:?}", grid.cells[0]);
1616 let has_footer_para = grid.cells[1].iter().any(|b| matches!(
1618 b,
1619 Block::Paragraph(inlines) if inlines.iter().any(|i| matches!(
1620 i,
1621 super::super::node::Inline::Text(t) if t.contains("footer cell")
1622 ))
1623 ));
1624 assert!(has_footer_para, "expected footer paragraph in cell[1]; got {:?}", grid.cells[1]);
1625 }
1626 other => panic!("expected Grid, got {other:?}"),
1627 }
1628 assert!(!result.markdown_with_placeholders.contains(":::grid 2"));
1631 }
1632
1633 #[test]
1634 fn arity_mismatch_does_not_close_block() {
1635 let md = "::::buttons\n[t](u)\n:::\n[t2](u2)\n::::\n";
1639 let result = extract_shortcodes(md);
1640 assert_eq!(result.extracted.len(), 1);
1642 match &result.extracted[0].shortcode {
1643 Shortcode::Buttons(args) => {
1644 assert_eq!(args.items.len(), 2);
1646 }
1647 _ => panic!("expected Buttons"),
1648 }
1649 }
1650
1651 #[test]
1654 fn extracts_buttons_block_with_one_link() {
1655 let md = ":::buttons\n[Documentation](docs/)\n:::\n";
1656 let result = extract_shortcodes(md);
1657 assert_eq!(result.extracted.len(), 1);
1658 match &result.extracted[0].shortcode {
1659 Shortcode::Buttons(args) => {
1660 assert!(args.classes.is_empty());
1661 assert_eq!(args.items.len(), 1);
1662 assert_eq!(args.items[0].text, "Documentation");
1663 match &args.items[0].url {
1664 Url::Unresolved(s) => assert_eq!(s, "docs/"),
1665 _ => panic!("expected Unresolved"),
1666 }
1667 }
1668 _ => panic!("expected Buttons"),
1669 }
1670 }
1671
1672 #[test]
1673 fn extracts_buttons_block_with_multiple_links() {
1674 let md = ":::buttons\n[Docs](docs/)\n[GitHub](https://github.com)\n:::\n";
1675 let result = extract_shortcodes(md);
1676 match &result.extracted[0].shortcode {
1677 Shortcode::Buttons(args) => {
1678 assert_eq!(args.items.len(), 2);
1679 assert_eq!(args.items[0].text, "Docs");
1680 assert_eq!(args.items[1].text, "GitHub");
1681 }
1682 _ => panic!("expected Buttons"),
1683 }
1684 }
1685
1686 #[test]
1687 fn extracts_buttons_block_with_class_attrs() {
1688 let md = ":::buttons {.primary .large}\n[Go](go/)\n:::\n";
1689 let result = extract_shortcodes(md);
1690 match &result.extracted[0].shortcode {
1691 Shortcode::Buttons(args) => {
1692 assert_eq!(args.classes, "primary large");
1693 assert_eq!(args.items.len(), 1);
1694 }
1695 _ => panic!("expected Buttons"),
1696 }
1697 }
1698
1699 #[test]
1700 fn extracts_buttons_with_moss_resolved_url_intact() {
1701 let md = ":::buttons\n[Docs](moss-resolved:docs/index.md)\n:::\n";
1705 let result = extract_shortcodes(md);
1706 match &result.extracted[0].shortcode {
1707 Shortcode::Buttons(args) => match &args.items[0].url {
1708 Url::Unresolved(s) => assert_eq!(s, "moss-resolved:docs/index.md"),
1709 _ => panic!("expected Unresolved"),
1710 },
1711 _ => panic!("expected Buttons"),
1712 }
1713 }
1714
1715 #[test]
1716 fn buttons_skips_non_link_lines() {
1717 let md = ":::buttons\nNot a link, just text.\n[Real](real/)\n\n:::\n";
1720 let result = extract_shortcodes(md);
1721 match &result.extracted[0].shortcode {
1722 Shortcode::Buttons(args) => {
1723 assert_eq!(args.items.len(), 1);
1724 assert_eq!(args.items[0].text, "Real");
1725 }
1726 _ => panic!("expected Buttons"),
1727 }
1728 }
1729
1730 #[test]
1731 fn buttons_inside_code_fence_is_not_extracted() {
1732 let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
1733 let result = extract_shortcodes(md);
1734 assert!(result.extracted.is_empty());
1735 }
1736
1737 #[test]
1738 fn extract_markdown_link_rejects_text_with_close_bracket() {
1739 let md = ":::buttons\n[a]b](u)\n:::\n";
1745 let result = extract_shortcodes(md);
1746 match &result.extracted[0].shortcode {
1747 Shortcode::Buttons(args) => assert!(args.items.is_empty()),
1748 _ => panic!("expected Buttons"),
1749 }
1750 }
1751
1752 #[test]
1753 fn extract_markdown_link_requires_trailing_paren() {
1754 let md = ":::buttons\n[t](u) <!-- trailing -->\n:::\n";
1757 let result = extract_shortcodes(md);
1758 match &result.extracted[0].shortcode {
1759 Shortcode::Buttons(args) => assert!(args.items.is_empty()),
1760 _ => panic!("expected Buttons"),
1761 }
1762 }
1763
1764 #[test]
1765 fn close_fence_with_trailing_whitespace_is_recognized() {
1766 let md = ":::subscribe\nbutton: x\n::: \n";
1769 let result = extract_shortcodes(md);
1770 assert_eq!(result.extracted.len(), 1);
1771 }
1772
1773 #[test]
1774 fn is_close_fence_handles_multibyte_utf8_lines() {
1775 assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 3));
1782 assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 4));
1783 assert!(!is_close_fence("中文内容", 3));
1785 assert!(!is_close_fence("日本語", 3));
1786 assert!(is_close_fence(":::", 3));
1788 assert!(is_close_fence("::::", 4));
1789 }
1790
1791 #[test]
1792 fn extract_shortcodes_handles_buttons_with_cjk_link_text() {
1793 let md = ":::buttons\n[申请测试版](#青苔正在封闭测试)\n[文档](docs/)\n:::\n";
1798 let result = extract_shortcodes(md);
1799 assert_eq!(result.extracted.len(), 1);
1800 match &result.extracted[0].shortcode {
1801 Shortcode::Buttons(args) => {
1802 assert_eq!(args.items.len(), 2);
1803 assert_eq!(args.items[0].text, "申请测试版");
1804 match &args.items[0].url {
1805 Url::Unresolved(s) => assert_eq!(s, "#青苔正在封闭测试"),
1806 _ => panic!("expected Unresolved"),
1807 }
1808 assert_eq!(args.items[1].text, "文档");
1809 }
1810 _ => panic!("expected Buttons"),
1811 }
1812 }
1813
1814 #[test]
1815 fn extract_shortcodes_does_not_panic_on_arbitrary_cjk_content() {
1816 let md = "# 标题\n\n中文段落,混合 English 单词。\n\n:::buttons\n[申请测试版](#锚点)\n:::\n\n## 二级标题\n\n更多内容。\n";
1822 let result = extract_shortcodes(md);
1823 assert_eq!(result.extracted.len(), 1);
1824 }
1825
1826 #[test]
1827 fn close_fence_with_trailing_text_is_not_recognized() {
1828 let md = ":::buttons\n[a](u)\n::: more text\n[b](v)\n:::\n";
1834 let result = extract_shortcodes(md);
1835 assert_eq!(result.extracted.len(), 1);
1836 match &result.extracted[0].shortcode {
1839 Shortcode::Buttons(args) => {
1840 assert_eq!(args.items.len(), 2);
1841 assert_eq!(args.items[0].text, "a");
1842 assert_eq!(args.items[1].text, "b");
1843 }
1844 _ => panic!("expected Buttons"),
1845 }
1846 }
1847
1848 #[test]
1851 fn extracts_gallery_with_bare_paths() {
1852 let md = ":::gallery\nphoto1.jpg\nphoto2.png\n:::\n";
1853 let result = extract_shortcodes(md);
1854 assert_eq!(result.extracted.len(), 1);
1855 match &result.extracted[0].shortcode {
1856 Shortcode::Gallery(args) => {
1857 assert!(args.columns.is_none());
1858 assert_eq!(args.items.len(), 2);
1859 assert_eq!(args.items[0].alt, "");
1860 match &args.items[0].src {
1861 Url::Unresolved(s) => assert_eq!(s, "photo1.jpg"),
1862 _ => panic!("expected Unresolved"),
1863 }
1864 }
1865 _ => panic!("expected Gallery"),
1866 }
1867 }
1868
1869 #[test]
1870 fn extracts_gallery_with_columns_arg() {
1871 let md = ":::gallery 4\na.jpg\n:::\n";
1872 let result = extract_shortcodes(md);
1873 match &result.extracted[0].shortcode {
1874 Shortcode::Gallery(args) => assert_eq!(args.columns, Some(4)),
1875 _ => panic!("expected Gallery"),
1876 }
1877 }
1878
1879 #[test]
1880 fn extracts_gallery_with_classes() {
1881 let md = ":::gallery 3 {.showcase}\na.jpg\n:::\n";
1882 let result = extract_shortcodes(md);
1883 match &result.extracted[0].shortcode {
1884 Shortcode::Gallery(args) => {
1885 assert_eq!(args.columns, Some(3));
1886 assert_eq!(args.classes, "showcase");
1887 }
1888 _ => panic!("expected Gallery"),
1889 }
1890 }
1891
1892 #[test]
1893 fn extracts_gallery_with_markdown_image_syntax() {
1894 let md = ":::gallery\n\n:::\n";
1895 let result = extract_shortcodes(md);
1896 match &result.extracted[0].shortcode {
1897 Shortcode::Gallery(args) => {
1898 assert_eq!(args.items[0].alt, "A photo");
1899 match &args.items[0].src {
1900 Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
1901 _ => panic!("expected Unresolved"),
1902 }
1903 }
1904 _ => panic!("expected Gallery"),
1905 }
1906 }
1907
1908 #[test]
1909 fn extracts_gallery_with_pipe_attrs() {
1910 let md = ":::gallery\nphoto.jpg|cover top\n:::\n";
1911 let result = extract_shortcodes(md);
1912 match &result.extracted[0].shortcode {
1913 Shortcode::Gallery(args) => {
1914 assert_eq!(args.items[0].attrs, "cover top");
1915 match &args.items[0].src {
1916 Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
1917 _ => panic!("expected Unresolved"),
1918 }
1919 }
1920 _ => panic!("expected Gallery"),
1921 }
1922 }
1923
1924 #[test]
1925 fn gallery_skips_blank_lines() {
1926 let md = ":::gallery\n\na.jpg\n\nb.jpg\n\n:::\n";
1927 let result = extract_shortcodes(md);
1928 match &result.extracted[0].shortcode {
1929 Shortcode::Gallery(args) => assert_eq!(args.items.len(), 2),
1930 _ => panic!("expected Gallery"),
1931 }
1932 }
1933
1934 #[test]
1940 fn extracts_buttons_with_multi_line_attrs() {
1941 let md = ":::buttons {\n .primary\n}\n[Go](go/)\n:::\n";
1944 let result = extract_shortcodes(md);
1945 assert_eq!(result.extracted.len(), 1);
1946 match &result.extracted[0].shortcode {
1947 Shortcode::Buttons(args) => {
1948 assert_eq!(args.classes, "primary");
1949 assert_eq!(args.items.len(), 1);
1950 assert_eq!(args.items[0].text, "Go");
1951 }
1952 _ => panic!("expected Buttons"),
1953 }
1954 }
1955
1956 #[test]
1957 fn extracts_gallery_with_multi_line_attrs() {
1958 let md = ":::gallery {\n .showcase\n}\nphoto.jpg\n:::\n";
1959 let result = extract_shortcodes(md);
1960 match &result.extracted[0].shortcode {
1961 Shortcode::Gallery(args) => {
1962 assert_eq!(args.classes, "showcase");
1963 assert_eq!(args.items.len(), 1);
1964 }
1965 _ => panic!("expected Gallery"),
1966 }
1967 }
1968
1969 #[test]
1970 fn multi_line_attrs_with_quoted_brace_inside() {
1971 let md = ":::buttons {\n .a\n .b\n}\n[Go](go/)\n:::\n";
1974 let result = extract_shortcodes(md);
1975 assert_eq!(result.extracted.len(), 1);
1976 match &result.extracted[0].shortcode {
1977 Shortcode::Buttons(args) => {
1978 assert_eq!(args.classes, "a b");
1980 }
1981 _ => panic!("expected Buttons"),
1982 }
1983 }
1984
1985 #[test]
1988 fn css_region_unnamed_emits_div_wrapper() {
1989 let md = ":::{.tagline}\nA new way to publish.\n:::\n";
1990 let result = extract_shortcodes(md);
1991 assert!(result.extracted.is_empty());
1992 assert!(result
1993 .markdown_with_placeholders
1994 .contains("<div class=\"tagline\">"));
1995 assert!(result
1996 .markdown_with_placeholders
1997 .contains("A new way to publish."));
1998 assert!(result.markdown_with_placeholders.contains("</div>"));
1999 }
2000
2001 #[test]
2002 fn css_region_with_id_only() {
2003 let md = ":::{#intro}\nIntro prose.\n:::\n";
2004 let result = extract_shortcodes(md);
2005 assert!(result
2006 .markdown_with_placeholders
2007 .contains("<div id=\"intro\">"));
2008 }
2009
2010 #[test]
2011 fn css_region_with_classes_and_id() {
2012 let md = ":::{.callout #important}\nWatch out.\n:::\n";
2013 let result = extract_shortcodes(md);
2014 let out = &result.markdown_with_placeholders;
2015 assert!(out.contains("<div"));
2016 assert!(out.contains("class=\"callout\""));
2017 assert!(out.contains("id=\"important\""));
2018 }
2019
2020 #[test]
2021 fn css_region_emits_blank_lines_around_body_for_markdown_processing() {
2022 let md = ":::{.foo}\n# Heading\n:::\n";
2025 let out = extract_shortcodes(md).markdown_with_placeholders;
2026 assert!(out.contains(">\n\n# Heading"));
2028 assert!(out.contains("# Heading\n\n</div>"));
2030 }
2031
2032 #[test]
2033 fn css_region_no_warning_emitted() {
2034 let md = ":::{.foo}\nbody\n:::\n";
2035 assert!(extract_shortcodes(md).warnings.is_empty());
2036 }
2037
2038 #[test]
2041 fn unknown_name_renders_fallback_wrapper() {
2042 let md = ":::nope {.extra}\nbody text\n:::\n";
2043 let result = extract_shortcodes(md);
2044 let out = &result.markdown_with_placeholders;
2045 assert!(out.contains("class=\"moss-unknown-shortcode extra\""));
2046 assert!(out.contains(r#"data-name="nope""#));
2047 assert!(out.contains("body text"));
2048 }
2049
2050 #[test]
2051 fn unknown_name_emits_build_warning() {
2052 let md = ":::nope\n:::\n";
2053 let warnings = extract_shortcodes(md).warnings;
2054 assert_eq!(warnings.len(), 1);
2055 assert!(warnings[0].contains("nope"));
2056 }
2057
2058 #[test]
2059 fn unknown_name_html_escapes_data_name() {
2060 let md = ":::weird-name\nbody\n:::\n";
2065 let out = extract_shortcodes(md).markdown_with_placeholders;
2066 assert!(out.contains(r#"data-name="weird-name""#));
2067 }
2068
2069 #[test]
2073 fn extracts_grid_with_positional_columns() {
2074 let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
2081 let result = extract_shortcodes(md);
2082 assert_eq!(result.extracted.len(), 1);
2083 match &result.extracted[0].shortcode {
2084 Shortcode::Grid(grid) => {
2085 assert_eq!(grid.columns, 2);
2086 assert!(grid.ratio.is_none());
2087 assert_eq!(grid.cells.len(), 2);
2088 assert_paragraph_text(&grid.cells[0], "cell A");
2089 assert_paragraph_text(&grid.cells[1], "cell B");
2090 }
2091 other => panic!("expected Grid, got {other:?}"),
2092 }
2093 }
2094
2095 fn assert_paragraph_text(cell_blocks: &[Block], expected: &str) {
2101 if cell_blocks.is_empty() && expected.is_empty() {
2102 return;
2103 }
2104 let para = match cell_blocks {
2105 [Block::Paragraph(inlines)] => inlines,
2106 other => panic!(
2107 "expected single Paragraph cell with text {expected:?}, got: {other:?}"
2108 ),
2109 };
2110 let mut text = String::new();
2111 for inline in para {
2112 match inline {
2113 super::super::node::Inline::Text(t) => text.push_str(t),
2114 super::super::node::Inline::Code(c) => text.push_str(c),
2115 _ => {}
2116 }
2117 }
2118 assert_eq!(text, expected, "cell text mismatch");
2119 }
2120
2121 #[test]
2122 fn extracts_grid_with_positional_ratio() {
2123 let md = ":::grid 2 1:2\nleft\n---\nright\n:::\n";
2124 let result = extract_shortcodes(md);
2125 match &result.extracted[0].shortcode {
2126 Shortcode::Grid(grid) => {
2127 assert_eq!(grid.columns, 2);
2128 assert_eq!(grid.ratio.as_deref(), Some("1:2"));
2129 }
2130 _ => panic!("expected Grid"),
2131 }
2132 }
2133
2134 #[test]
2135 fn extracts_grid_with_cols_attr_integer() {
2136 let md = ":::grid {cols=3}\nA\n+++\nB\n+++\nC\n:::\n";
2137 let result = extract_shortcodes(md);
2138 match &result.extracted[0].shortcode {
2139 Shortcode::Grid(grid) => {
2140 assert_eq!(grid.columns, 3);
2141 assert_eq!(grid.cells.len(), 3);
2142 assert_paragraph_text(&grid.cells[0], "A");
2143 assert_paragraph_text(&grid.cells[1], "B");
2144 assert_paragraph_text(&grid.cells[2], "C");
2145 }
2146 _ => panic!("expected Grid"),
2147 }
2148 }
2149
2150 #[test]
2151 fn extracts_grid_with_cols_attr_ratio_implies_count() {
2152 let md = ":::grid {cols=1:1:2}\nA\n+++\nB\n+++\nC\n:::\n";
2153 let result = extract_shortcodes(md);
2154 match &result.extracted[0].shortcode {
2155 Shortcode::Grid(grid) => {
2156 assert_eq!(grid.columns, 3, "ratio length implies column count");
2157 assert_eq!(grid.ratio.as_deref(), Some("1:1:2"));
2158 }
2159 _ => panic!("expected Grid"),
2160 }
2161 }
2162
2163 #[test]
2164 fn extracts_grid_accepts_plus_plus_plus_divider() {
2165 let md = ":::grid 2\nA\n+++\nB\n:::\n";
2166 let result = extract_shortcodes(md);
2167 match &result.extracted[0].shortcode {
2168 Shortcode::Grid(grid) => {
2169 assert_eq!(grid.cells.len(), 2);
2170 assert_paragraph_text(&grid.cells[0], "A");
2171 assert_paragraph_text(&grid.cells[1], "B");
2172 }
2173 _ => panic!("expected Grid"),
2174 }
2175 }
2176
2177 #[test]
2178 fn extracts_grid_with_classes() {
2179 let md = ":::grid 3 {.work-cards .featured}\nA\n---\nB\n---\nC\n:::\n";
2180 let result = extract_shortcodes(md);
2181 match &result.extracted[0].shortcode {
2182 Shortcode::Grid(grid) => {
2183 assert_eq!(grid.columns, 3);
2184 assert_eq!(grid.classes, "work-cards featured");
2185 }
2186 _ => panic!("expected Grid"),
2187 }
2188 }
2189
2190 #[test]
2191 fn extracts_grid_single_cell_no_separator() {
2192 let md = ":::grid 1\nonly cell\n:::\n";
2193 let result = extract_shortcodes(md);
2194 match &result.extracted[0].shortcode {
2195 Shortcode::Grid(grid) => {
2196 assert_eq!(grid.columns, 1);
2197 assert_eq!(grid.cells.len(), 1);
2198 assert_paragraph_text(&grid.cells[0], "only cell");
2199 }
2200 _ => panic!("expected Grid"),
2201 }
2202 }
2203
2204 #[test]
2205 fn extracts_grid_with_empty_middle_cell() {
2206 let md = ":::grid 3\nA\n+++\n+++\nC\n:::\n";
2211 let result = extract_shortcodes(md);
2212 match &result.extracted[0].shortcode {
2213 Shortcode::Grid(grid) => {
2214 assert_eq!(grid.cells.len(), 3);
2215 assert_paragraph_text(&grid.cells[0], "A");
2216 assert!(grid.cells[1].is_empty(), "empty cell should have no blocks");
2217 assert_paragraph_text(&grid.cells[2], "C");
2218 }
2219 _ => panic!("expected Grid"),
2220 }
2221 }
2222
2223 #[test]
2224 fn nested_grid_via_arity_is_unsupported_authoring() {
2225 let md = "::::grid 1\n:::grid 2\nA\n+++\nB\n:::\n::::\n";
2237 let result = extract_shortcodes(md);
2238 assert_eq!(result.extracted.len(), 1);
2242 match &result.extracted[0].shortcode {
2243 Shortcode::Grid(outer) => {
2244 assert_eq!(outer.columns, 1);
2245 assert!(outer.cells.len() >= 2,
2248 "outer's body got split by inner's +++, demonstrating the \
2249 unsupported-nesting failure mode");
2250 }
2251 _ => panic!("expected Grid"),
2252 }
2253 }
2254
2255 #[test]
2256 fn extracts_grid_with_compound_link_cell_typed_as_link_card() {
2257 let md = ":::grid 2 {.work-cards}\n[![[poster.jpg]]\n#### Title\nbody](/url)\n+++\n[Card 2](/url2)\n:::\n";
2266 let result = extract_shortcodes(md);
2267 match &result.extracted[0].shortcode {
2268 Shortcode::Grid(grid) => {
2269 assert_eq!(grid.classes, "work-cards");
2270 assert_eq!(grid.cells.len(), 2);
2271 match &grid.cells[0][..] {
2272 [Block::LinkCard { url, children }] => {
2273 match url {
2274 Url::Unresolved(u) => assert_eq!(u, "/url"),
2275 _ => panic!("expected Unresolved /url"),
2276 }
2277 assert!(!children.is_empty(), "compound-link inner blocks empty");
2281 }
2282 other => panic!("expected single LinkCard cell, got {other:?}"),
2283 }
2284 match &grid.cells[1][..] {
2285 [Block::LinkCard { url, .. }] => match url {
2286 Url::Unresolved(u) => assert_eq!(u, "/url2"),
2287 _ => panic!("expected Unresolved /url2"),
2288 },
2289 other => panic!("expected LinkCard for cell[1], got {other:?}"),
2290 }
2291 }
2292 _ => panic!("expected Grid"),
2293 }
2294 }
2295
2296 #[test]
2301 fn toc_now_renders_as_unknown_shortcode() {
2302 let md = ":::toc\n:::\n";
2307 let result = extract_shortcodes(md);
2308 assert!(result.extracted.is_empty(), "toc is no longer typed");
2309 assert_eq!(result.warnings.len(), 1, "unknown-name fallback warning");
2310 assert!(result.warnings[0].contains("toc"));
2311 assert!(result
2312 .markdown_with_placeholders
2313 .contains(r#"data-name="toc""#));
2314 }
2315
2316 #[test]
2319 fn extracts_hero_block_with_no_image() {
2320 let md = ":::hero\n# A House of Daowu\n:::\n";
2321 let result = extract_shortcodes(md);
2322 assert_eq!(result.extracted.len(), 1, "hero should be extracted");
2323 match &result.extracted[0].shortcode {
2324 Shortcode::Hero(args) => {
2325 assert!(args.image.is_none());
2326 assert_eq!(args.overlay_text, "# A House of Daowu");
2327 }
2328 other => panic!("expected Hero, got {other:?}"),
2329 }
2330 assert!(!result.markdown_with_placeholders.contains(":::hero"));
2332 }
2333
2334 #[test]
2335 fn extracts_hero_block_with_wikilink_body_image() {
2336 let md = ":::hero\n![[panorama.jpg]]\n# Welcome\n:::\n";
2337 let result = extract_shortcodes(md);
2338 assert_eq!(result.extracted.len(), 1);
2339 match &result.extracted[0].shortcode {
2340 Shortcode::Hero(args) => {
2341 match &args.image {
2342 Some(Url::Unresolved(s)) => assert_eq!(s, "panorama.jpg"),
2343 other => panic!("expected Unresolved url, got {other:?}"),
2344 }
2345 assert_eq!(args.overlay_text, "# Welcome");
2346 }
2347 other => panic!("expected Hero, got {other:?}"),
2348 }
2349 }
2350
2351 #[test]
2352 fn extracts_hero_block_with_image_attr() {
2353 let md = ":::hero {image=cover.jpg}\n# Title\n:::\n";
2354 let result = extract_shortcodes(md);
2355 match &result.extracted[0].shortcode {
2356 Shortcode::Hero(args) => {
2357 match &args.image {
2358 Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
2359 other => panic!("expected Unresolved, got {other:?}"),
2360 }
2361 assert_eq!(args.overlay_text, "# Title");
2362 }
2363 other => panic!("expected Hero, got {other:?}"),
2364 }
2365 }
2366
2367 #[test]
2368 fn extracts_hero_block_with_image_attr_and_pipe_attrs() {
2369 let md = r#":::hero {image="cover.jpg|contain top"}
2372:::
2373"#;
2374 let result = extract_shortcodes(md);
2375 match &result.extracted[0].shortcode {
2376 Shortcode::Hero(args) => {
2377 match &args.image {
2378 Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
2379 _ => panic!("expected Unresolved"),
2380 }
2381 assert_eq!(args.attrs, "contain top");
2382 }
2383 _ => panic!("expected Hero"),
2384 }
2385 }
2386
2387 #[test]
2388 fn extracts_hero_block_with_classes() {
2389 let md = ":::hero {.full .center}\n# Title\n:::\n";
2390 let result = extract_shortcodes(md);
2391 match &result.extracted[0].shortcode {
2392 Shortcode::Hero(args) => {
2393 assert_eq!(args.classes, "full center");
2394 }
2395 _ => panic!("expected Hero"),
2396 }
2397 }
2398
2399 #[test]
2400 fn extracts_hero_block_with_directive_line_path() {
2401 let md = ":::hero ./assets/header.png\n:::\n";
2407 let result = extract_shortcodes(md);
2408 assert_eq!(result.extracted.len(), 1);
2409 match &result.extracted[0].shortcode {
2410 Shortcode::Hero(args) => match &args.image {
2411 Some(Url::Unresolved(s)) => assert_eq!(s, "./assets/header.png"),
2412 other => panic!("expected Unresolved ./assets/header.png, got {other:?}"),
2413 },
2414 _ => panic!("expected Hero"),
2415 }
2416 }
2417
2418 #[test]
2419 fn extracts_hero_block_with_directive_line_path_and_pipe_attrs() {
2420 let md = ":::hero ./bg.jpg|contain top\n:::\n";
2421 let result = extract_shortcodes(md);
2422 match &result.extracted[0].shortcode {
2423 Shortcode::Hero(args) => {
2424 match &args.image {
2425 Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
2426 _ => panic!("expected Unresolved"),
2427 }
2428 assert_eq!(args.attrs, "contain top");
2429 }
2430 _ => panic!("expected Hero"),
2431 }
2432 }
2433
2434 #[test]
2435 fn extracts_hero_block_with_directive_line_path_and_classes() {
2436 let md = ":::hero ./bg.jpg {.landing}\n# Welcome\n:::\n";
2439 let result = extract_shortcodes(md);
2440 match &result.extracted[0].shortcode {
2441 Shortcode::Hero(args) => {
2442 match &args.image {
2443 Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
2444 _ => panic!("expected Unresolved"),
2445 }
2446 assert_eq!(args.classes, "landing");
2447 assert_eq!(args.overlay_text, "# Welcome");
2448 }
2449 _ => panic!("expected Hero"),
2450 }
2451 }
2452
2453 #[test]
2456 fn nested_css_region_outer_closes_at_first_inner_close() {
2457 let md = ":::{.outer}\n:::{.inner}\nbody\n:::\n:::\n";
2466 let result = extract_shortcodes(md);
2467 let out = &result.markdown_with_placeholders;
2468 assert!(out.contains("<div class=\"outer\""));
2470 assert!(out.contains(":::{.inner}"));
2473 }
2474
2475 #[test]
2476 fn nested_css_region_higher_arity_outer_recurses_into_inner() {
2477 let md = "::::{.outer}\n:::{.inner}\nbody\n:::\n::::\n";
2482 let result = extract_shortcodes(md);
2483 let out = &result.markdown_with_placeholders;
2484 assert!(out.contains("<div class=\"outer\""));
2485 assert!(out.contains("<div class=\"inner\""));
2486 assert!(!out.contains(":::{.inner}"));
2488 }
2489
2490 #[test]
2491 fn css_region_containing_typed_subscribe_is_not_recursively_extracted() {
2492 let md = ":::{.wrapper}\n:::subscribe\n:::\n:::\n";
2495 let result = extract_shortcodes(md);
2496 assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
2500 assert!(result.extracted.is_empty());
2502 }
2503
2504 #[test]
2505 fn higher_arity_wrapper_recursively_extracts_typed_subscribe() {
2506 let md = "::::{.wrapper}\n:::subscribe\n:::\n::::\n";
2512 let result = extract_shortcodes(md);
2513 assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
2514 assert_eq!(result.extracted.len(), 1);
2515 match &result.extracted[0].shortcode {
2516 Shortcode::Subscribe(_) => {}
2517 _ => panic!("expected Subscribe"),
2518 }
2519 assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
2522 }
2523
2524 #[test]
2525 fn lower_arity_outer_wraps_higher_arity_typed_inner() {
2526 let md = ":::{.support-band}\n## Title\n\n::::buttons {.inverted}\n[Support Us](/support)\n::::\n*footnote*\n:::\n";
2532 let result = extract_shortcodes(md);
2533 let out = &result.markdown_with_placeholders;
2534 assert!(out.contains("<div class=\"support-band\""));
2536 assert_eq!(result.extracted.len(), 1);
2538 match &result.extracted[0].shortcode {
2539 Shortcode::Buttons(args) => {
2540 assert_eq!(args.items.len(), 1);
2541 }
2542 _ => panic!("expected Buttons"),
2543 }
2544 assert!(!out.contains("::::buttons"));
2546 assert!(!out.contains("::::"));
2547 }
2548
2549 #[test]
2550 fn lower_arity_outer_wraps_grid_with_buttons_in_cell() {
2551 let md = "::: {.hero-split}\n::::grid 2 {.no-cards}\nleft\n+++\nright\n::::\n:::\n";
2558 let result = extract_shortcodes(md);
2559 let out = &result.markdown_with_placeholders;
2560 assert!(out.contains("<div class=\"hero-split\""));
2562 assert_eq!(result.extracted.len(), 1);
2564 match &result.extracted[0].shortcode {
2565 Shortcode::Grid(_) => {}
2566 _ => panic!("expected Grid"),
2567 }
2568 assert!(!out.contains("::::grid"));
2570 }
2571
2572 #[test]
2573 fn unknown_name_body_recursively_extracts_typed_inner() {
2574 let md = ":::buttosn\n::::buttons\n[a](u)\n::::\n:::\n";
2580 let result = extract_shortcodes(md);
2581 let out = &result.markdown_with_placeholders;
2582 assert!(out.contains("data-name=\"buttosn\""));
2584 assert_eq!(result.extracted.len(), 1);
2586 match &result.extracted[0].shortcode {
2587 Shortcode::Buttons(_) => {}
2588 _ => panic!("expected Buttons"),
2589 }
2590 }
2591
2592 #[test]
2593 fn unknown_name_with_plus_plus_plus_in_body_passes_through() {
2594 let md = ":::buttosn\n[a](u)\n+++\n[b](v)\n:::\n";
2600 let result = extract_shortcodes(md);
2601 let out = &result.markdown_with_placeholders;
2602 assert!(out.contains(r#"data-name="buttosn""#));
2603 assert!(out.contains("[a](u)"));
2604 assert!(out.contains("+++"));
2605 assert!(out.contains("[b](v)"));
2606 }
2607
2608 #[test]
2609 fn parse_shortcode_opener_recognizes_empty_name_with_attrs() {
2610 assert_eq!(
2611 parse_shortcode_opener(":::{.tagline}"),
2612 Some((3, "", "{.tagline}"))
2613 );
2614 }
2615
2616 #[test]
2617 fn parse_shortcode_opener_rejects_just_colons() {
2618 assert!(parse_shortcode_opener(":::").is_none());
2619 assert!(parse_shortcode_opener("::: ").is_none());
2620 }
2621
2622 #[test]
2623 fn unclosed_multi_line_attrs_block_emits_verbatim() {
2624 let md = ":::buttons {\n .primary\n[Go](go/)\n:::\n";
2627 let result = extract_shortcodes(md);
2628 assert!(result.extracted.is_empty() || matches!(result.extracted[0].shortcode, Shortcode::Buttons(_)));
2633 }
2635
2636 #[test]
2639 fn grid_legacy_dash_emits_deprecation_warning() {
2640 let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
2641 let result = extract_shortcodes(md);
2642 assert_eq!(result.warnings.len(), 1);
2643 assert!(result.warnings[0].contains("deprecated"));
2644 assert!(result.warnings[0].contains("+++"));
2645 }
2646
2647 #[test]
2648 fn grid_plus_plus_plus_no_deprecation_warning() {
2649 let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
2650 let result = extract_shortcodes(md);
2651 assert!(result.warnings.is_empty());
2652 }
2653
2654 #[test]
2655 fn hero_priority3_body_image_emits_deprecation_warning() {
2656 let md = ":::hero\nphoto.jpg\n# Title\n:::\n";
2657 let result = extract_shortcodes(md);
2658 assert_eq!(result.warnings.len(), 1);
2659 assert!(result.warnings[0].contains("deprecated"));
2660 assert!(result.warnings[0].contains("image="));
2661 }
2662
2663 #[test]
2664 fn hero_explicit_image_attr_no_deprecation_warning() {
2665 let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
2666 let result = extract_shortcodes(md);
2667 assert!(result.warnings.is_empty());
2668 }
2669
2670 fn first_extracted(md: &str) -> Shortcode {
2678 let result = extract_shortcodes(md);
2679 result
2680 .extracted
2681 .into_iter()
2682 .next()
2683 .expect("at least one shortcode")
2684 .shortcode
2685 }
2686
2687 #[test]
2688 fn hero_with_full_flag_sets_width_screen() {
2689 let md = ":::hero {image=photo.jpg full}\n# Title\n:::\n";
2690 match first_extracted(md) {
2691 Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
2692 other => panic!("expected Hero, got {other:?}"),
2693 }
2694 }
2695
2696 #[test]
2697 fn hero_with_screen_flag_sets_width_screen() {
2698 let md = ":::hero {image=photo.jpg screen}\n# Title\n:::\n";
2699 match first_extracted(md) {
2700 Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
2701 other => panic!("expected Hero, got {other:?}"),
2702 }
2703 }
2704
2705 #[test]
2706 fn hero_with_wide_flag_sets_width_wide() {
2707 let md = ":::hero {image=photo.jpg wide}\n# Title\n:::\n";
2708 match first_extracted(md) {
2709 Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("wide")),
2710 other => panic!("expected Hero, got {other:?}"),
2711 }
2712 }
2713
2714 #[test]
2715 fn hero_without_width_flag_leaves_width_none() {
2716 let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
2717 match first_extracted(md) {
2718 Shortcode::Hero(h) => assert!(h.width.is_none(), "got {:?}", h.width),
2719 other => panic!("expected Hero, got {other:?}"),
2720 }
2721 }
2722
2723 #[test]
2724 fn gallery_with_page_flag_sets_width_page() {
2725 let md = ":::gallery 3 {page}\nphoto.jpg\n:::\n";
2726 match first_extracted(md) {
2727 Shortcode::Gallery(g) => assert_eq!(g.width.as_deref(), Some("page")),
2728 other => panic!("expected Gallery, got {other:?}"),
2729 }
2730 }
2731
2732 #[test]
2733 fn gallery_without_width_flag_leaves_width_none() {
2734 let md = ":::gallery 3\nphoto.jpg\n:::\n";
2735 match first_extracted(md) {
2736 Shortcode::Gallery(g) => assert!(g.width.is_none()),
2737 other => panic!("expected Gallery, got {other:?}"),
2738 }
2739 }
2740
2741 #[test]
2742 fn grid_with_wide_flag_sets_width_wide() {
2743 let md = ":::grid {cols=2 wide}\ncell A\n+++\ncell B\n:::\n";
2744 match first_extracted(md) {
2745 Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("wide")),
2746 other => panic!("expected Grid, got {other:?}"),
2747 }
2748 }
2749
2750 #[test]
2751 fn grid_with_full_flag_normalizes_to_screen() {
2752 let md = ":::grid {cols=2 full}\ncell A\n+++\ncell B\n:::\n";
2753 match first_extracted(md) {
2754 Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("screen")),
2755 other => panic!("expected Grid, got {other:?}"),
2756 }
2757 }
2758
2759 #[test]
2760 fn grid_without_width_flag_leaves_width_none() {
2761 let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
2762 match first_extracted(md) {
2763 Shortcode::Grid(g) => assert!(g.width.is_none()),
2764 other => panic!("expected Grid, got {other:?}"),
2765 }
2766 }
2767
2768 #[test]
2771 fn parses_recent_with_since_and_count() {
2772 let (sc, warns) = parse_shortcode_block(
2773 "recent",
2774 r#"{since="2026-04-01" count="5"}"#,
2775 "",
2776 );
2777 assert!(warns.is_empty());
2778 match sc.expect("expected Some(Shortcode)") {
2779 Shortcode::Recent(args) => {
2780 assert_eq!(args.since.as_deref(), Some("2026-04-01"));
2781 assert_eq!(args.count, Some(5));
2782 assert!(args.last.is_none());
2783 assert!(args.fallback_markdown.is_empty());
2784 }
2785 other => panic!("expected Recent, got {other:?}"),
2786 }
2787 }
2788
2789 #[test]
2790 fn parses_recent_with_last_window() {
2791 let (sc, _) = parse_shortcode_block("recent", r#"{last="month"}"#, "");
2792 match sc.expect("expected Some(Shortcode)") {
2793 Shortcode::Recent(args) => {
2794 assert_eq!(args.last.as_deref(), Some("month"));
2795 assert!(args.since.is_none());
2796 assert!(args.count.is_none());
2797 }
2798 other => panic!("expected Recent, got {other:?}"),
2799 }
2800 }
2801
2802 #[test]
2803 fn captures_recent_body_as_fallback_markdown() {
2804 let body = "No posts yet. [Follow along](/).";
2805 let (sc, _) = parse_shortcode_block("recent", "", body);
2806 match sc.expect("expected Some(Shortcode)") {
2807 Shortcode::Recent(args) => {
2808 assert_eq!(args.fallback_markdown, body);
2809 }
2810 other => panic!("expected Recent, got {other:?}"),
2811 }
2812 }
2813
2814 #[test]
2815 fn recent_with_no_args_yields_all_none() {
2816 let (sc, warns) = parse_shortcode_block("recent", "", "");
2817 assert!(warns.is_empty());
2818 match sc.expect("expected Some(Shortcode)") {
2819 Shortcode::Recent(args) => {
2820 assert!(args.since.is_none());
2821 assert!(args.last.is_none());
2822 assert!(args.count.is_none());
2823 assert!(args.fallback_markdown.is_empty());
2824 }
2825 other => panic!("expected Recent, got {other:?}"),
2826 }
2827 }
2828
2829 #[test]
2830 fn parses_recent_with_all_three_attrs() {
2831 let (sc, warns) = parse_shortcode_block(
2832 "recent",
2833 r#"{since="2026-01-01" last="month" count="3"}"#,
2834 "",
2835 );
2836 assert!(warns.is_empty());
2837 match sc.expect("expected Some(Shortcode)") {
2838 Shortcode::Recent(args) => {
2839 assert_eq!(args.since.as_deref(), Some("2026-01-01"));
2840 assert_eq!(args.last.as_deref(), Some("month"));
2841 assert_eq!(args.count, Some(3));
2842 }
2843 other => panic!("expected Recent, got {other:?}"),
2844 }
2845 }
2846
2847 #[test]
2848 fn recent_with_malformed_count_yields_none_count() {
2849 let (sc, _) = parse_shortcode_block("recent", r#"{count="lots"}"#, "");
2853 match sc.expect("expected Some(Shortcode)") {
2854 Shortcode::Recent(args) => assert!(args.count.is_none()),
2855 other => panic!("expected Recent, got {other:?}"),
2856 }
2857 }
2858
2859 #[test]
2860 fn recent_body_is_trimmed() {
2861 let (sc, _) = parse_shortcode_block("recent", "", "\n hello world \n\n");
2864 match sc.expect("expected Some(Shortcode)") {
2865 Shortcode::Recent(args) => assert_eq!(args.fallback_markdown, "hello world"),
2866 other => panic!("expected Recent, got {other:?}"),
2867 }
2868 }
2869
2870 #[test]
2871 fn extracts_recent_end_to_end_with_sentinel() {
2872 let md = ":::recent {since=\"2026-04-01\" count=\"5\"}\nNo posts yet.\n:::\n";
2876 let result = extract_shortcodes(md);
2877 assert_eq!(result.extracted.len(), 1);
2878 match &result.extracted[0].shortcode {
2879 Shortcode::Recent(args) => {
2880 assert_eq!(args.since.as_deref(), Some("2026-04-01"));
2881 assert_eq!(args.count, Some(5));
2882 assert_eq!(args.fallback_markdown, "No posts yet.");
2883 }
2884 other => panic!("expected Recent, got {other:?}"),
2885 }
2886 assert!(!result.markdown_with_placeholders.contains(":::recent"));
2887 assert!(result
2888 .markdown_with_placeholders
2889 .contains(&placeholder_for(&result.nonce, 0)));
2890 }
2891}