1use super::attrs::gather_multi_line_attrs;
22use super::cells::split_cells;
23use super::node::Block;
24use super::parser::{parse_with_config, ParseConfig};
25use super::shortcode::{
26 ApplyShortcode, ButtonItem, ButtonsShortcode, GalleryItem, GalleryShortcode, GridShortcode,
27 HeroShortcode, RecentShortcode, Shortcode, SubscribeShortcode,
28};
29use super::url::Url;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ExtractedShortcode {
34 pub index: usize,
36 pub shortcode: Shortcode,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ExtractionResult {
43 pub markdown_with_placeholders: String,
46 pub extracted: Vec<ExtractedShortcode>,
48 pub nonce: String,
56 pub warnings: Vec<String>,
60}
61
62const TYPED_KNOWN: &[&str] = &["subscribe", "buttons", "gallery", "hero", "grid", "recent", "apply"];
66
67fn is_typed_known(name: &str) -> bool {
68 TYPED_KNOWN.contains(&name)
69}
70
71fn parse_shortcode_block(
80 name: &str,
81 args: &str,
82 body: &str,
83 config: &ParseConfig,
84) -> (Option<Shortcode>, Vec<String>) {
85 match name {
86 "subscribe" => (Some(Shortcode::Subscribe(parse_subscribe_args(args))), vec![]),
87 "buttons" => (Some(Shortcode::Buttons(parse_buttons_body(args, body))), vec![]),
88 "gallery" => (Some(Shortcode::Gallery(parse_gallery_body(args, body))), vec![]),
89 "hero" => {
90 let (sc, used_p3) = parse_hero(args, body, config);
91 let mut warns = vec![];
92 if used_p3 {
93 warns.push(
94 "shortcode `:::hero` uses a body-image fallback (deprecated Priority 3). \
95 Move the image path to the `image=` attribute: \
96 `:::hero {image=path.jpg}`."
97 .to_string(),
98 );
99 }
100 if let Some(ref v) = sc.mobile {
101 if v != "overlay" {
102 warns.push(format!(
103 "shortcode `:::hero` has unrecognized `mobile={v}`. \
104 Only `mobile=overlay` is recognized. The attribute is ignored."
105 ));
106 }
107 }
108 (Some(Shortcode::Hero(sc)), warns)
109 }
110 "grid" => {
111 let (sc, legacy) = parse_grid(args, body, config);
112 let mut warns = vec![];
113 if legacy {
114 warns.push(
115 "shortcode `:::grid` uses `---` cell dividers (deprecated). Migrate to `+++`.\n\
116 `---` support will be removed in a future release."
117 .to_string(),
118 );
119 }
120 (Some(Shortcode::Grid(sc)), warns)
121 }
122 "recent" => (Some(Shortcode::Recent(parse_recent_args(args, body))), vec![]),
123 "apply" => (Some(Shortcode::Apply(parse_apply_args(args))), vec![]),
124 _ => (None, vec![]),
125 }
126}
127
128pub fn parse_recent_args(args: &str, body: &str) -> RecentShortcode {
141 let attrs = super::attrs::parse_attrs(args).unwrap_or_default();
142 RecentShortcode {
143 since: attrs.get("since").map(str::to_string),
144 last: attrs.get("last").map(str::to_string),
145 count: attrs.get("count").and_then(|v| v.parse::<u32>().ok()),
146 fallback_markdown: body.trim().to_string(),
147 }
148}
149
150fn parse_grid(args: &str, body: &str, config: &ParseConfig) -> (GridShortcode, bool) {
167 let trimmed = args.trim();
168 let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed.find('{') {
169 #[allow(clippy::string_slice)]
171 (trimmed[..pos].trim(), &trimmed[pos..])
172 } else {
173 (trimmed, "")
174 };
175
176 let parsed = if attr_block.is_empty() {
177 Default::default()
178 } else {
179 super::attrs::parse_attrs(attr_block).unwrap_or_default()
180 };
181 let classes = parsed.class_string();
182 let width = parsed.width.map(str::to_string);
183
184 let mut columns: u32 = 1;
185 let mut ratio: Option<String> = None;
186
187 if let Some(cols_value) = parsed.get("cols") {
188 if cols_value.contains(':') {
189 ratio = Some(cols_value.to_string());
190 columns = cols_value.split(':').count() as u32;
191 } else if let Ok(n) = cols_value.parse::<u32>() {
192 columns = n.max(1);
193 }
194 } else {
195 let parts: Vec<&str> = positional.split_whitespace().collect();
197 if let Some(first) = parts.first() {
198 if first.contains(':') {
199 ratio = Some(first.to_string());
200 columns = first.split(':').count() as u32;
201 } else if let Ok(n) = first.parse::<u32>() {
202 columns = n.max(1);
203 if let Some(second) = parts.get(1) {
204 if second.contains(':') {
205 ratio = Some(second.to_string());
206 }
207 }
208 }
209 }
210 }
211
212 let (raw_cells, found_legacy_dash) = split_grid_cells(body);
213
214 let cells: Vec<Vec<Block>> = raw_cells
229 .iter()
230 .map(|raw| parse_cell_to_blocks(raw, config))
231 .collect();
232
233 (
234 GridShortcode {
235 columns,
236 ratio,
237 classes,
238 cells,
239 width,
240 },
241 found_legacy_dash,
242 )
243}
244
245fn parse_cell_to_blocks(raw: &str, config: &ParseConfig) -> Vec<Block> {
253 if let Some((url, inner)) = detect_compound_link(raw) {
254 let inner_trimmed = inner.trim();
255 let inner_is_plain_text = !inner_trimmed.contains('!')
271 && !inner_trimmed.contains('[')
272 && !inner_trimmed.contains('\n');
273 let is_external = url.starts_with("http://") || url.starts_with("https://");
274 if inner_is_plain_text && is_external {
275 let linkified = format!("[{}]({})", inner_trimmed, url);
278 return parse_with_config(&linkified, config).blocks;
279 }
280 let inner_doc = parse_with_config(inner_trimmed, config);
281 return vec![Block::LinkCard {
282 url: Url::unresolved(url),
283 children: inner_doc.blocks,
284 }];
285 }
286 if let Some(url) = detect_bare_url_cell(raw) {
299 let linkified = format!("[]({})", url);
300 let doc = parse_with_config(&linkified, config);
301 return doc.blocks;
302 }
303 let doc = parse_with_config(raw, config);
304 doc.blocks
305}
306
307fn detect_bare_url_cell(cell_text: &str) -> Option<String> {
315 let trimmed = cell_text.trim();
316 if trimmed.is_empty() {
317 return None;
318 }
319 if trimmed.lines().count() > 1 {
320 return None;
321 }
322 if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
323 return None;
324 }
325 if trimmed.chars().any(char::is_whitespace) {
326 return None;
327 }
328 Some(trimmed.to_string())
329}
330
331pub(super) fn detect_compound_link(cell_text: &str) -> Option<(String, String)> {
355 let stripped = cell_text.trim();
356
357 if !stripped.starts_with('[') {
358 return None;
359 }
360 if !stripped.ends_with(')') {
361 return None;
362 }
363 if stripped.len() > 1 && stripped.as_bytes()[1] == b'`' {
364 return None;
365 }
366
367 for line in stripped.lines() {
368 let t = line.trim();
369 if t.starts_with("```") || t.starts_with("~~~") {
370 return None;
371 }
372 }
373
374 let bytes = stripped.as_bytes();
375
376 let mut i: usize = 1;
378 let mut depth: usize = 1;
379 let mut outer_close: Option<usize> = None;
380
381 while i < bytes.len() {
382 match bytes[i] {
383 b'\\' => {
384 i += 2;
385 continue;
386 }
387 b'`' => {
388 let tick_start = i;
389 while i < bytes.len() && bytes[i] == b'`' {
390 i += 1;
391 }
392 let fence_len = i - tick_start;
393 'code_scan: while i < bytes.len() {
394 if bytes[i] == b'`' {
395 let close_start = i;
396 while i < bytes.len() && bytes[i] == b'`' {
397 i += 1;
398 }
399 if i - close_start == fence_len {
400 break 'code_scan;
401 }
402 } else {
403 i += 1;
404 }
405 }
406 continue;
407 }
408 b'[' => {
409 depth += 1;
410 }
411 b']' => {
412 depth -= 1;
413 if depth == 0 {
414 outer_close = Some(i);
415 break;
416 }
417 }
418 _ => {}
419 }
420 i += 1;
421 }
422
423 let close_bracket = outer_close?;
424
425 if bytes.get(close_bracket + 1) != Some(&b'(') {
426 return None;
427 }
428
429 let mut j = close_bracket + 2;
431 let mut pdepth: usize = 1;
432 let mut paren_close: Option<usize> = None;
433
434 while j < bytes.len() {
435 match bytes[j] {
436 b'\\' => {
437 j += 2;
438 continue;
439 }
440 b'(' => pdepth += 1,
441 b')' => {
442 pdepth -= 1;
443 if pdepth == 0 {
444 paren_close = Some(j);
445 break;
446 }
447 }
448 _ => {}
449 }
450 j += 1;
451 }
452
453 let close_paren = paren_close?;
454
455 let tail = &stripped[close_paren + 1..];
457 if !tail.chars().all(|c| c.is_whitespace()) {
458 return None;
459 }
460
461 let inner = &stripped[1..close_bracket];
463 if inner.trim().is_empty() {
464 return None;
465 }
466
467 {
469 let inner_bytes = inner.as_bytes();
470 let mut k: usize = 0;
471 let mut image_stack: Vec<bool> = Vec::new();
472
473 while k < inner_bytes.len() {
474 match inner_bytes[k] {
475 b'\\' => {
476 k += 2;
477 continue;
478 }
479 b'`' => {
480 let tick_start = k;
481 while k < inner_bytes.len() && inner_bytes[k] == b'`' {
482 k += 1;
483 }
484 let fence_len = k - tick_start;
485 'inner_code: while k < inner_bytes.len() {
486 if inner_bytes[k] == b'`' {
487 let cs = k;
488 while k < inner_bytes.len() && inner_bytes[k] == b'`' {
489 k += 1;
490 }
491 if k - cs == fence_len {
492 break 'inner_code;
493 }
494 } else {
495 k += 1;
496 }
497 }
498 continue;
499 }
500 b'[' => {
501 let preceded_by_bang = k > 0 && inner_bytes[k - 1] == b'!';
502 image_stack.push(preceded_by_bang);
503 }
504 b']' => {
505 if let Some(is_image) = image_stack.pop() {
506 if image_stack.is_empty() && inner_bytes.get(k + 1) == Some(&b'(') {
507 if !is_image {
508 return None;
509 }
510 }
511 }
512 }
513 _ => {}
514 }
515 k += 1;
516 }
517 }
518
519 let url = &stripped[close_bracket + 2..close_paren];
520 Some((url.to_string(), inner.to_string()))
521}
522
523fn split_grid_cells(body: &str) -> (Vec<String>, bool) {
534 if body.is_empty() {
535 return (vec![String::new()], false);
536 }
537 let mut cells = Vec::new();
538 let mut current = String::new();
539 let mut first_line_in_cell = true;
540 let mut found_legacy_dash = false;
541
542 for line in body.split_inclusive('\n') {
543 let content_no_eol = line.strip_suffix('\n').unwrap_or(line);
544 let trimmed = content_no_eol.trim();
545 if trimmed == "+++" || trimmed == "---" {
546 if trimmed == "---" {
547 found_legacy_dash = true;
548 }
549 if let Some(stripped) = current.strip_suffix('\n') {
550 current.truncate(stripped.len());
551 }
552 cells.push(std::mem::take(&mut current));
553 first_line_in_cell = true;
554 continue;
555 }
556 if first_line_in_cell {
557 first_line_in_cell = false;
558 if trimmed.is_empty() {
559 continue;
560 }
561 }
562 current.push_str(line);
563 }
564 if let Some(stripped) = current.strip_suffix('\n') {
565 current.truncate(stripped.len());
566 }
567 cells.push(current);
568 (cells, found_legacy_dash)
569}
570
571fn parse_hero(args: &str, body: &str, config: &ParseConfig) -> (HeroShortcode, bool) {
589 let trimmed_args = args.trim();
590
591 let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed_args.find('{') {
594 #[allow(clippy::string_slice)]
596 (trimmed_args[..pos].trim(), &trimmed_args[pos..])
597 } else {
598 (trimmed_args, "")
599 };
600
601 let parsed = if attr_block.is_empty() {
603 Default::default()
604 } else {
605 super::attrs::parse_attrs(attr_block).unwrap_or_default()
606 };
607 let classes = parsed.class_string();
608 let width = parsed.width.map(str::to_string);
609 let mobile = parsed.get("mobile").map(str::to_string);
610
611 if let Some(image_value) = parsed.get("image") {
613 let (path, attrs_str) = crate::media::split_pipe(image_value);
614 let overlay_text = body.trim().to_string();
615 let overlay = parse_overlay_to_blocks(&overlay_text, config);
616 return (
617 HeroShortcode {
618 image: if path.trim().is_empty() {
619 None
620 } else {
621 Some(Url::unresolved(path.trim().to_string()))
622 },
623 attrs: attrs_str.to_string(),
624 classes,
625 overlay,
626 overlay_text,
627 width,
628 mobile,
629 },
630 false,
631 );
632 }
633
634 if !positional.is_empty() {
638 let (path, attrs_str) = crate::media::split_pipe(positional);
639 let overlay_text = body.trim().to_string();
640 let overlay = parse_overlay_to_blocks(&overlay_text, config);
641 return (
642 HeroShortcode {
643 image: if path.trim().is_empty() {
644 None
645 } else {
646 Some(Url::unresolved(path.trim().to_string()))
647 },
648 attrs: attrs_str.to_string(),
649 classes,
650 overlay,
651 overlay_text,
652 width,
653 mobile,
654 },
655 false,
656 );
657 }
658
659 let mut overlay_lines: Vec<&str> = Vec::new();
661 let mut image_path: Option<String> = None;
662 let mut image_attrs = String::new();
663 let mut found_image = false;
664 let mut used_priority_3 = false;
665 for line in body.lines() {
666 if !found_image && !line.trim().is_empty() {
667 if let Some((path, attrs_str)) = parse_hero_media_line(line) {
668 image_path = Some(path);
669 image_attrs = attrs_str;
670 found_image = true;
671 used_priority_3 = true;
672 continue;
673 }
674 found_image = true;
676 }
677 overlay_lines.push(line);
678 }
679 let overlay_text = overlay_lines.join("\n").trim().to_string();
680 let overlay = parse_overlay_to_blocks(&overlay_text, config);
681 (
682 HeroShortcode {
683 image: image_path.map(Url::unresolved),
684 attrs: image_attrs,
685 classes,
686 overlay,
687 overlay_text,
688 width,
689 mobile,
690 },
691 used_priority_3,
692 )
693}
694
695fn parse_overlay_to_blocks(raw: &str, config: &ParseConfig) -> Vec<Block> {
702 if raw.is_empty() {
703 return Vec::new();
704 }
705 let doc = parse_with_config(raw, config);
706 doc.blocks
707}
708
709const HERO_MEDIA_EXTENSIONS: &[&str] = &[
711 "jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov",
712];
713
714fn is_bare_hero_media(s: &str) -> bool {
715 let (path_part, _) = crate::media::split_pipe(s);
716 let path = path_part.trim();
717 path.rfind('.')
718 .map(|dot| {
719 #[allow(clippy::string_slice)]
722 let ext = &path[dot + 1..];
723 HERO_MEDIA_EXTENSIONS
724 .iter()
725 .any(|e| e.eq_ignore_ascii_case(ext))
726 })
727 .unwrap_or(false)
728}
729
730fn parse_hero_media_line(line: &str) -> Option<(String, String)> {
732 let trimmed = line.trim();
733
734 if let Some(inner) = trimmed
736 .strip_prefix("![[")
737 .and_then(|s| s.strip_suffix("]]"))
738 {
739 let (path, attrs_str) = crate::media::split_pipe(inner);
740 return Some((path.trim().to_string(), attrs_str.to_string()));
741 }
742
743 if trimmed.starts_with(" {
746 if trimmed.ends_with(')') {
747 #[allow(clippy::string_slice)]
751 let inner = &trimmed[paren_open + 2..trimmed.len() - 1];
752 let (path, attrs_str) = crate::media::split_pipe(inner);
753 return Some((path.trim().to_string(), attrs_str.to_string()));
754 }
755 }
756 }
757
758 if is_bare_hero_media(trimmed) {
760 let (path, attrs_str) = crate::media::split_pipe(trimmed);
761 return Some((path.trim().to_string(), attrs_str.to_string()));
762 }
763
764 None
765}
766
767fn parse_gallery_body(args: &str, body: &str) -> GalleryShortcode {
768 let (positional, classes, width) = split_positional_classes_and_width(args);
772 let columns = if positional.is_empty() {
773 None
774 } else {
775 positional.parse::<u32>().ok()
776 };
777 let mut items: Vec<GalleryItem> = Vec::new();
778 for line in body.lines() {
779 let trimmed = line.trim();
780 if trimmed.is_empty() {
781 continue;
782 }
783 let (src_raw, attrs) = split_pipe(trimmed);
786 let (src_url, alt) = match parse_markdown_image(src_raw) {
787 Some((alt, path)) => (path, alt),
788 None => (src_raw.trim().to_string(), String::new()),
789 };
790 items.push(GalleryItem {
791 src: Url::unresolved(src_url),
792 alt,
793 attrs: attrs.to_string(),
794 });
795 }
796 GalleryShortcode {
797 columns,
798 classes,
799 items,
800 width,
801 }
802}
803
804fn split_positional_classes_and_width(args: &str) -> (String, String, Option<String>) {
812 let trimmed = args.trim();
813 if let Some(brace_start) = trimmed.find('{') {
814 #[allow(clippy::string_slice)]
815 let after_open = &trimmed[brace_start..];
816 if let Some(brace_end) = after_open.find('}') {
817 #[allow(clippy::string_slice)]
818 let positional = trimmed[..brace_start].trim().to_string();
819 #[allow(clippy::string_slice)]
820 let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
821 if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
822 return (
823 positional,
824 parsed.class_string(),
825 parsed.width.map(str::to_string),
826 );
827 }
828 #[allow(clippy::string_slice)]
833 let inner = &trimmed[brace_start + 1..brace_start + brace_end];
834 let mut classes = Vec::new();
835 for token in inner.split_whitespace() {
836 if let Some(class) = token.strip_prefix('.') {
837 if !class.is_empty() {
838 classes.push(class);
839 }
840 }
841 }
842 return (positional, classes.join(" "), None);
843 }
844 }
845 (trimmed.to_string(), String::new(), None)
846}
847
848fn split_positional_and_classes(args: &str) -> (String, String) {
862 let trimmed = args.trim();
863 if let Some(brace_start) = trimmed.find('{') {
864 #[allow(clippy::string_slice)]
868 let after_open = &trimmed[brace_start..];
869 if let Some(brace_end) = after_open.find('}') {
870 #[allow(clippy::string_slice)]
874 let positional = trimmed[..brace_start].trim().to_string();
875 #[allow(clippy::string_slice)]
876 let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
877 if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
878 return (positional, parsed.class_string());
879 }
880 #[allow(clippy::string_slice)]
883 let inner = &trimmed[brace_start + 1..brace_start + brace_end];
884 let mut classes = Vec::new();
885 for token in inner.split_whitespace() {
886 if let Some(class) = token.strip_prefix('.') {
887 if !class.is_empty() {
888 classes.push(class);
889 }
890 }
891 }
892 return (positional, classes.join(" "));
893 }
894 }
895 (trimmed.to_string(), String::new())
896}
897
898fn split_pipe(s: &str) -> (&str, &str) {
900 match s.split_once('|') {
901 Some((before, after)) => (before, after.trim()),
902 None => (s, ""),
903 }
904}
905
906fn parse_markdown_image(s: &str) -> Option<(String, String)> {
909 let s = s.trim();
910 let rest = s.strip_prefix("?;
912 let close_paren = after.rfind(')')?;
913 #[allow(clippy::string_slice)]
915 let path = &after[..close_paren];
916 if path.contains('(') {
917 return None;
918 }
919 Some((alt.to_string(), path.to_string()))
920}
921
922fn parse_buttons_body(args: &str, body: &str) -> ButtonsShortcode {
923 let (_positional, classes) = split_positional_and_classes(args);
924 let mut items: Vec<ButtonItem> = Vec::new();
925 for cell in split_cells(body) {
930 for line in cell.lines() {
931 let trimmed = line.trim();
932 if trimmed.is_empty() {
933 continue;
934 }
935 if let Some((text, url)) = extract_markdown_link(trimmed) {
936 items.push(ButtonItem {
937 text,
938 url: Url::unresolved(url),
939 });
940 }
941 }
943 }
944 ButtonsShortcode { classes, items }
945}
946
947fn extract_markdown_link(s: &str) -> Option<(String, String)> {
950 let s = s.trim();
951 let inside = s.strip_prefix('[')?;
952 let (text, after) = inside.split_once(']')?;
953 let url = after.strip_prefix('(').and_then(|r| r.strip_suffix(')'))?;
954 if url.is_empty() {
955 return None;
956 }
957 Some((text.to_string(), url.to_string()))
958}
959
960fn parse_subscribe_args(args: &str) -> SubscribeShortcode {
967 let parsed = match super::attrs::parse_attrs(args) {
970 Ok(b) => b,
971 Err(_) => return SubscribeShortcode::default(),
972 };
973 let placeholder = parsed
974 .get("placeholder")
975 .filter(|s| !s.is_empty())
976 .map(str::to_string);
977 let button = parsed
978 .get("button")
979 .filter(|s| !s.is_empty())
980 .map(str::to_string);
981 SubscribeShortcode {
982 placeholder,
983 button,
984 }
985}
986
987pub fn parse_apply_args(args: &str) -> ApplyShortcode {
993 let parsed = match super::attrs::parse_attrs(args) {
994 Ok(b) => b,
995 Err(_) => return ApplyShortcode::default(),
996 };
997 let placeholder = parsed
998 .get("placeholder")
999 .filter(|s| !s.is_empty())
1000 .map(str::to_string);
1001 let button = parsed
1002 .get("button")
1003 .filter(|s| !s.is_empty())
1004 .map(str::to_string);
1005 ApplyShortcode {
1006 placeholder,
1007 button,
1008 }
1009}
1010
1011pub fn placeholder_for(nonce: &str, index: usize) -> String {
1019 format!("<!--MOSS_SC_{nonce}_{index}-->")
1020}
1021
1022pub fn parse_placeholder(nonce: &str, html: &str) -> Option<usize> {
1028 let trim = html.trim();
1029 let prefix = format!("<!--MOSS_SC_{nonce}_");
1030 let inner = trim.strip_prefix(&prefix)?;
1031 let inner = inner.strip_suffix("-->")?;
1032 inner.parse::<usize>().ok()
1033}
1034
1035fn compute_nonce(input: &str) -> String {
1040 use std::hash::{Hash, Hasher};
1041 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1042 input.hash(&mut hasher);
1043 let h = hasher.finish() as u32;
1049 format!("{h:08x}")
1050}
1051
1052pub fn extract_shortcodes(markdown: &str) -> ExtractionResult {
1060 extract_shortcodes_with_config(markdown, &ParseConfig::default())
1061}
1062
1063pub fn extract_shortcodes_with_config(
1073 markdown: &str,
1074 config: &ParseConfig,
1075) -> ExtractionResult {
1076 let nonce = compute_nonce(markdown);
1077 let mut extracted: Vec<ExtractedShortcode> = Vec::new();
1078 let mut warnings: Vec<String> = Vec::new();
1079 let output = extract_with_state(markdown, &nonce, &mut extracted, &mut warnings, config);
1080 ExtractionResult {
1081 markdown_with_placeholders: output,
1082 extracted,
1083 nonce,
1084 warnings,
1085 }
1086}
1087
1088fn extract_with_state(
1095 markdown: &str,
1096 nonce: &str,
1097 extracted: &mut Vec<ExtractedShortcode>,
1098 warnings: &mut Vec<String>,
1099 config: &ParseConfig,
1100) -> String {
1101 let mut output = String::with_capacity(markdown.len());
1102 let lines: Vec<&str> = markdown.lines().collect();
1103 let mut i = 0;
1104 let mut in_code_fence = false;
1105 let mut fence_marker = String::new();
1106
1107 while i < lines.len() {
1108 let line = lines[i];
1109 let trimmed = line.trim();
1110
1111 if in_code_fence {
1113 output.push_str(line);
1114 output.push('\n');
1115 let fence_char = fence_marker.chars().next().unwrap_or(' ');
1123 if trimmed.starts_with(&fence_marker)
1124 && trimmed.trim_start_matches(fence_char).trim().is_empty()
1125 {
1126 in_code_fence = false;
1127 fence_marker.clear();
1128 }
1129 i += 1;
1130 continue;
1131 }
1132 if let Some(marker) = detect_code_fence_open(trimmed) {
1133 in_code_fence = true;
1134 fence_marker = marker;
1135 output.push_str(line);
1136 output.push('\n');
1137 i += 1;
1138 continue;
1139 }
1140
1141 if let Some((arity, name, single_line_args)) = parse_shortcode_opener(trimmed) {
1143 let (args_owned, opener_lines_consumed) =
1152 gather_multi_line_attrs(single_line_args, &lines[i + 1..]);
1153 let args: &str = args_owned.as_deref().unwrap_or(single_line_args);
1154 let body_start = i + 1 + opener_lines_consumed;
1155
1156 let mut body_lines: Vec<&str> = Vec::new();
1158 let mut j = body_start;
1159 let mut closed = false;
1160 while j < lines.len() {
1161 if is_close_fence(lines[j].trim(), arity) {
1162 closed = true;
1163 break;
1164 }
1165 body_lines.push(lines[j]);
1166 j += 1;
1167 }
1168
1169 if !closed {
1170 output.push_str(line);
1173 output.push('\n');
1174 i += 1;
1175 continue;
1176 }
1177
1178 let body = body_lines.join("\n");
1179
1180 if name.is_empty() {
1196 let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1204 let body_processed = extract_with_state(&body, nonce, extracted, warnings, config);
1205 output.push_str(&render_div_open(&parsed.classes, parsed.id.as_deref(), None));
1206 output.push_str("\n\n");
1207 output.push_str(&body_processed);
1208 if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1209 output.push('\n');
1210 }
1211 output.push_str("\n</div>\n");
1212 i = j + 1;
1213 continue;
1214 }
1215
1216 if is_typed_known(name) {
1217 if let (Some(sc), parse_warnings) = parse_shortcode_block(name, args, &body, config) {
1218 warnings.extend(parse_warnings);
1219 let index = extracted.len();
1220 output.push_str(&placeholder_for(&nonce, index));
1221 output.push('\n');
1222 for _ in 0..(j - i) {
1233 output.push('\n');
1234 }
1235 extracted.push(ExtractedShortcode {
1236 index,
1237 shortcode: sc,
1238 });
1239 i = j + 1;
1240 continue;
1241 }
1242 output.push_str(line);
1246 output.push('\n');
1247 i += 1;
1248 continue;
1249 }
1250
1251 let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1256 warnings.push(format!("unknown shortcode `:::{}`", name));
1257 let mut classes = vec!["moss-unknown-shortcode".to_string()];
1258 classes.extend(parsed.classes.iter().cloned());
1259 let extra_attrs = format!(r#" data-name="{}""#, html_escape_attr(name));
1260 let body_processed = extract_with_state(&body, nonce, extracted, warnings, config);
1261 output.push_str(&render_div_open(&classes, parsed.id.as_deref(), Some(&extra_attrs)));
1262 output.push_str("\n\n");
1263 output.push_str(&body_processed);
1264 if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1265 output.push('\n');
1266 }
1267 output.push_str("\n</div>\n");
1268 i = j + 1;
1269 continue;
1270 }
1271
1272 output.push_str(line);
1274 output.push('\n');
1275 i += 1;
1276 }
1277
1278 output
1279}
1280
1281fn render_div_open(classes: &[String], id: Option<&str>, extra_attrs: Option<&str>) -> String {
1286 let mut out = String::from("<div");
1287 if !classes.is_empty() {
1288 out.push_str(" class=\"");
1289 for (i, c) in classes.iter().enumerate() {
1290 if i > 0 {
1291 out.push(' ');
1292 }
1293 out.push_str(&html_escape_attr(c));
1294 }
1295 out.push('"');
1296 }
1297 if let Some(id_val) = id {
1298 out.push_str(" id=\"");
1299 out.push_str(&html_escape_attr(id_val));
1300 out.push('"');
1301 }
1302 if let Some(extra) = extra_attrs {
1303 out.push_str(extra);
1304 }
1305 out.push('>');
1306 out
1307}
1308
1309fn html_escape_attr(s: &str) -> String {
1312 let mut out = String::with_capacity(s.len());
1313 for c in s.chars() {
1314 match c {
1315 '&' => out.push_str("&"),
1316 '<' => out.push_str("<"),
1317 '>' => out.push_str(">"),
1318 '"' => out.push_str("""),
1319 '\'' => out.push_str("'"),
1320 _ => out.push(c),
1321 }
1322 }
1323 out
1324}
1325
1326fn detect_code_fence_open(trimmed: &str) -> Option<String> {
1327 if trimmed.starts_with("```") {
1328 Some("```".to_string())
1329 } else if trimmed.starts_with("~~~") {
1330 Some("~~~".to_string())
1331 } else {
1332 None
1333 }
1334}
1335
1336fn parse_shortcode_opener(trimmed: &str) -> Option<(usize, &str, &str)> {
1349 let colons = trimmed.chars().take_while(|&c| c == ':').count();
1350 if colons < 3 {
1351 return None;
1352 }
1353 #[allow(clippy::string_slice)]
1357 let rest = &trimmed[colons..];
1358 let name_end = rest
1360 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
1361 .unwrap_or(rest.len());
1362 if name_end == 0 {
1363 let after_ws = rest.trim_start();
1366 if !after_ws.starts_with('{') {
1367 return None;
1368 }
1369 return Some((colons, "", rest.trim()));
1370 }
1371 #[allow(clippy::string_slice)]
1374 let name = &rest[..name_end];
1375 #[allow(clippy::string_slice)]
1376 let args = rest[name_end..].trim();
1377 Some((colons, name, args))
1378}
1379
1380fn is_close_fence(trimmed: &str, arity: usize) -> bool {
1397 let mut chars = trimmed.chars();
1398 for _ in 0..arity {
1399 match chars.next() {
1400 Some(':') => {}
1401 _ => return false,
1402 }
1403 }
1404 chars.all(char::is_whitespace)
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411
1412 #[test]
1413 fn no_shortcodes_round_trips_input() {
1414 let md = "# Heading\n\npara with [link](u).\n";
1415 let result = extract_shortcodes(md);
1416 assert_eq!(result.markdown_with_placeholders, md);
1417 assert!(result.extracted.is_empty());
1418 }
1419
1420 #[test]
1421 fn extracts_subscribe_block_with_placeholder_and_button_attrs() {
1422 let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
1423:::
1424"#;
1425 let result = extract_shortcodes(md);
1426 assert_eq!(result.extracted.len(), 1);
1427 match &result.extracted[0].shortcode {
1428 Shortcode::Subscribe(args) => {
1429 assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
1430 assert_eq!(args.button.as_deref(), Some("Sign me up"));
1431 }
1432 other => panic!("expected Subscribe, got {other:?}"),
1433 }
1434 assert!(result
1435 .markdown_with_placeholders
1436 .contains(&placeholder_for(&result.nonce, 0)));
1437 assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
1438 }
1439
1440 #[test]
1441 fn extracts_subscribe_block_with_only_placeholder_attr() {
1442 let md = r#":::subscribe {placeholder="hi@example.com"}
1443:::
1444"#;
1445 let result = extract_shortcodes(md);
1446 match &result.extracted[0].shortcode {
1447 Shortcode::Subscribe(args) => {
1448 assert_eq!(args.placeholder.as_deref(), Some("hi@example.com"));
1449 assert!(args.button.is_none());
1450 }
1451 other => panic!("expected Subscribe, got {other:?}"),
1452 }
1453 }
1454
1455 #[test]
1456 fn extracts_subscribe_block_with_no_args() {
1457 let md = ":::subscribe\n:::\n";
1458 let result = extract_shortcodes(md);
1459 match &result.extracted[0].shortcode {
1460 Shortcode::Subscribe(args) => {
1461 assert!(args.placeholder.is_none());
1462 assert!(args.button.is_none());
1463 }
1464 other => panic!("expected Subscribe, got {other:?}"),
1465 }
1466 }
1467
1468 #[test]
1469 fn extracts_subscribe_block_with_multi_line_attrs() {
1470 let md = r#":::subscribe {
1471 placeholder="you@domain.com"
1472 button="Request access"
1473}
1474:::
1475"#;
1476 let result = extract_shortcodes(md);
1477 match &result.extracted[0].shortcode {
1478 Shortcode::Subscribe(args) => {
1479 assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
1480 assert_eq!(args.button.as_deref(), Some("Request access"));
1481 }
1482 other => panic!("expected Subscribe, got {other:?}"),
1483 }
1484 }
1485
1486 #[test]
1487 fn subscribe_legacy_body_keys_no_longer_parsed() {
1488 let md = ":::subscribe\ndescription: Get updates\n:::\n";
1492 let result = extract_shortcodes(md);
1493 match &result.extracted[0].shortcode {
1494 Shortcode::Subscribe(args) => {
1495 assert!(args.placeholder.is_none(), "old description body must not populate placeholder");
1496 assert!(args.button.is_none());
1497 }
1498 other => panic!("expected Subscribe, got {other:?}"),
1499 }
1500 }
1501
1502 #[test]
1503 fn subscribe_inside_code_fence_is_not_extracted() {
1504 let md = "```\n:::subscribe\ndescription: doc\n:::\n```\n";
1508 let result = extract_shortcodes(md);
1509 assert!(result.extracted.is_empty());
1510 assert!(result.markdown_with_placeholders.contains(":::subscribe"));
1511 }
1512
1513 #[test]
1514 fn subscribe_inside_tilde_fence_is_not_extracted() {
1515 let md = "~~~\n:::subscribe\n:::\n~~~\n";
1516 let result = extract_shortcodes(md);
1517 assert!(result.extracted.is_empty());
1518 }
1519
1520 #[test]
1521 fn unclosed_subscribe_block_emits_verbatim() {
1522 let md = ":::subscribe\nbutton: Go\n";
1524 let result = extract_shortcodes(md);
1525 assert!(result.extracted.is_empty());
1526 assert!(result.markdown_with_placeholders.contains(":::subscribe"));
1527 }
1528
1529 #[test]
1530 fn extracts_hero_block_with_body_image_typed() {
1531 let md = ":::hero\n![[bg.jpg]]\n:::\n";
1535 let result = extract_shortcodes(md);
1536 assert_eq!(result.extracted.len(), 1);
1537 match &result.extracted[0].shortcode {
1538 Shortcode::Hero(args) => match &args.image {
1539 Some(Url::Unresolved(s)) => assert_eq!(s, "bg.jpg"),
1540 _ => panic!("expected Unresolved bg.jpg"),
1541 },
1542 _ => panic!("expected Hero"),
1543 }
1544 assert!(!result.markdown_with_placeholders.contains(":::hero"));
1546 }
1547
1548 #[test]
1549 fn extracts_multiple_subscribes_with_increasing_indices() {
1550 let md = ":::subscribe\ndescription: a\n:::\n\nsome text\n\n:::subscribe\nbutton: b\n:::\n";
1551 let result = extract_shortcodes(md);
1552 assert_eq!(result.extracted.len(), 2);
1553 assert_eq!(result.extracted[0].index, 0);
1554 assert_eq!(result.extracted[1].index, 1);
1555 assert!(result
1556 .markdown_with_placeholders
1557 .contains(&placeholder_for(&result.nonce, 0)));
1558 assert!(result
1559 .markdown_with_placeholders
1560 .contains(&placeholder_for(&result.nonce, 1)));
1561 }
1562
1563 #[test]
1564 fn parse_placeholder_round_trips_index() {
1565 let nonce = "deadbeef";
1566 for index in [0, 1, 5, 99] {
1567 let s = placeholder_for(nonce, index);
1568 assert_eq!(parse_placeholder(nonce, &s), Some(index));
1569 }
1570 }
1571
1572 #[test]
1573 fn parse_placeholder_rejects_non_placeholder_html() {
1574 let nonce = "deadbeef";
1575 assert!(parse_placeholder(nonce, "<div>hi</div>").is_none());
1576 assert!(parse_placeholder(nonce, "<!--just a comment-->").is_none());
1577 }
1578
1579 #[test]
1580 fn parse_placeholder_rejects_wrong_nonce() {
1581 let s = placeholder_for("aaaa1111", 5);
1587 assert_eq!(parse_placeholder("bbbb2222", &s), None);
1588 }
1589
1590 #[test]
1591 fn extract_uses_content_derived_nonce() {
1592 let md = ":::subscribe\n:::\n";
1595 let r1 = extract_shortcodes(md);
1596 let r2 = extract_shortcodes(md);
1597 assert_eq!(r1.nonce, r2.nonce);
1598 let r3 = extract_shortcodes(":::subscribe\ndescription: x\n:::\n");
1601 assert_ne!(r1.nonce, r3.nonce);
1602 }
1603
1604 #[test]
1605 fn nonce_makes_authored_collision_inert() {
1606 let md = ":::subscribe\n:::\n\nLook: <!--MOSS_SC_00000000_0-->\n";
1610 let result = extract_shortcodes(md);
1611 assert_ne!(result.nonce, "00000000");
1614 assert!(result
1615 .markdown_with_placeholders
1616 .contains("MOSS_SC_00000000_0"));
1617 }
1618
1619 #[test]
1620 fn parse_shortcode_opener_recognizes_simple_name() {
1621 assert_eq!(
1622 parse_shortcode_opener(":::subscribe"),
1623 Some((3, "subscribe", ""))
1624 );
1625 }
1626
1627 #[test]
1628 fn parse_shortcode_opener_extracts_args() {
1629 assert_eq!(
1630 parse_shortcode_opener(":::grid 3 1:2:1"),
1631 Some((3, "grid", "3 1:2:1"))
1632 );
1633 }
1634
1635 #[test]
1636 fn parse_shortcode_opener_recognizes_quadruple_colon() {
1637 assert_eq!(
1640 parse_shortcode_opener("::::buttons"),
1641 Some((4, "buttons", ""))
1642 );
1643 }
1644
1645 #[test]
1646 fn parse_shortcode_opener_rejects_two_colons() {
1647 assert!(parse_shortcode_opener("::name").is_none());
1649 }
1650
1651 #[test]
1652 fn extracts_quadruple_colon_buttons() {
1653 let md = "::::buttons\n[Tickets](go/)\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_eq!(args.items.len(), 1);
1661 assert_eq!(args.items[0].text, "Tickets");
1662 }
1663 _ => panic!("expected Buttons"),
1664 }
1665 }
1666
1667 #[test]
1668 fn extracts_grid_with_nested_buttons_via_arity() {
1669 let md = ":::grid 2\n::::buttons\n[Tickets](go/)\n::::\n+++\nfooter cell\n:::\n";
1676 let result = extract_shortcodes(md);
1677 assert_eq!(result.extracted.len(), 1);
1678 match &result.extracted[0].shortcode {
1679 Shortcode::Grid(grid) => {
1680 assert_eq!(grid.columns, 2);
1681 assert_eq!(grid.cells.len(), 2);
1682 let has_typed_buttons = grid.cells[0].iter().any(|b| matches!(
1684 b,
1685 Block::Shortcode(Shortcode::Buttons(args)) if args.items.len() == 1
1686 && args.items[0].text == "Tickets"
1687 ));
1688 assert!(has_typed_buttons, "expected typed Buttons in cell[0]; got {:?}", grid.cells[0]);
1689 let has_footer_para = grid.cells[1].iter().any(|b| matches!(
1691 b,
1692 Block::Paragraph(inlines) if inlines.iter().any(|i| matches!(
1693 i,
1694 super::super::node::Inline::Text(t) if t.contains("footer cell")
1695 ))
1696 ));
1697 assert!(has_footer_para, "expected footer paragraph in cell[1]; got {:?}", grid.cells[1]);
1698 }
1699 other => panic!("expected Grid, got {other:?}"),
1700 }
1701 assert!(!result.markdown_with_placeholders.contains(":::grid 2"));
1704 }
1705
1706 #[test]
1707 fn arity_mismatch_does_not_close_block() {
1708 let md = "::::buttons\n[t](u)\n:::\n[t2](u2)\n::::\n";
1712 let result = extract_shortcodes(md);
1713 assert_eq!(result.extracted.len(), 1);
1715 match &result.extracted[0].shortcode {
1716 Shortcode::Buttons(args) => {
1717 assert_eq!(args.items.len(), 2);
1719 }
1720 _ => panic!("expected Buttons"),
1721 }
1722 }
1723
1724 #[test]
1727 fn extracts_buttons_block_with_one_link() {
1728 let md = ":::buttons\n[Documentation](docs/)\n:::\n";
1729 let result = extract_shortcodes(md);
1730 assert_eq!(result.extracted.len(), 1);
1731 match &result.extracted[0].shortcode {
1732 Shortcode::Buttons(args) => {
1733 assert!(args.classes.is_empty());
1734 assert_eq!(args.items.len(), 1);
1735 assert_eq!(args.items[0].text, "Documentation");
1736 match &args.items[0].url {
1737 Url::Unresolved(s) => assert_eq!(s, "docs/"),
1738 _ => panic!("expected Unresolved"),
1739 }
1740 }
1741 _ => panic!("expected Buttons"),
1742 }
1743 }
1744
1745 #[test]
1746 fn extracts_buttons_block_with_multiple_links() {
1747 let md = ":::buttons\n[Docs](docs/)\n[GitHub](https://github.com)\n:::\n";
1748 let result = extract_shortcodes(md);
1749 match &result.extracted[0].shortcode {
1750 Shortcode::Buttons(args) => {
1751 assert_eq!(args.items.len(), 2);
1752 assert_eq!(args.items[0].text, "Docs");
1753 assert_eq!(args.items[1].text, "GitHub");
1754 }
1755 _ => panic!("expected Buttons"),
1756 }
1757 }
1758
1759 #[test]
1760 fn extracts_buttons_block_with_class_attrs() {
1761 let md = ":::buttons {.primary .large}\n[Go](go/)\n:::\n";
1762 let result = extract_shortcodes(md);
1763 match &result.extracted[0].shortcode {
1764 Shortcode::Buttons(args) => {
1765 assert_eq!(args.classes, "primary large");
1766 assert_eq!(args.items.len(), 1);
1767 }
1768 _ => panic!("expected Buttons"),
1769 }
1770 }
1771
1772 #[test]
1773 fn extracts_buttons_with_moss_resolved_url_intact() {
1774 let md = ":::buttons\n[Docs](moss-resolved:docs/index.md)\n:::\n";
1778 let result = extract_shortcodes(md);
1779 match &result.extracted[0].shortcode {
1780 Shortcode::Buttons(args) => match &args.items[0].url {
1781 Url::Unresolved(s) => assert_eq!(s, "moss-resolved:docs/index.md"),
1782 _ => panic!("expected Unresolved"),
1783 },
1784 _ => panic!("expected Buttons"),
1785 }
1786 }
1787
1788 #[test]
1789 fn buttons_skips_non_link_lines() {
1790 let md = ":::buttons\nNot a link, just text.\n[Real](real/)\n\n:::\n";
1793 let result = extract_shortcodes(md);
1794 match &result.extracted[0].shortcode {
1795 Shortcode::Buttons(args) => {
1796 assert_eq!(args.items.len(), 1);
1797 assert_eq!(args.items[0].text, "Real");
1798 }
1799 _ => panic!("expected Buttons"),
1800 }
1801 }
1802
1803 #[test]
1804 fn buttons_inside_code_fence_is_not_extracted() {
1805 let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
1806 let result = extract_shortcodes(md);
1807 assert!(result.extracted.is_empty());
1808 }
1809
1810 #[test]
1811 fn extract_markdown_link_rejects_text_with_close_bracket() {
1812 let md = ":::buttons\n[a]b](u)\n:::\n";
1818 let result = extract_shortcodes(md);
1819 match &result.extracted[0].shortcode {
1820 Shortcode::Buttons(args) => assert!(args.items.is_empty()),
1821 _ => panic!("expected Buttons"),
1822 }
1823 }
1824
1825 #[test]
1826 fn extract_markdown_link_requires_trailing_paren() {
1827 let md = ":::buttons\n[t](u) <!-- trailing -->\n:::\n";
1830 let result = extract_shortcodes(md);
1831 match &result.extracted[0].shortcode {
1832 Shortcode::Buttons(args) => assert!(args.items.is_empty()),
1833 _ => panic!("expected Buttons"),
1834 }
1835 }
1836
1837 #[test]
1838 fn close_fence_with_trailing_whitespace_is_recognized() {
1839 let md = ":::subscribe\nbutton: x\n::: \n";
1842 let result = extract_shortcodes(md);
1843 assert_eq!(result.extracted.len(), 1);
1844 }
1845
1846 #[test]
1847 fn is_close_fence_handles_multibyte_utf8_lines() {
1848 assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 3));
1855 assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 4));
1856 assert!(!is_close_fence("中文内容", 3));
1858 assert!(!is_close_fence("日本語", 3));
1859 assert!(is_close_fence(":::", 3));
1861 assert!(is_close_fence("::::", 4));
1862 }
1863
1864 #[test]
1865 fn extract_shortcodes_handles_buttons_with_cjk_link_text() {
1866 let md = ":::buttons\n[申请测试版](#青苔正在封闭测试)\n[文档](docs/)\n:::\n";
1871 let result = extract_shortcodes(md);
1872 assert_eq!(result.extracted.len(), 1);
1873 match &result.extracted[0].shortcode {
1874 Shortcode::Buttons(args) => {
1875 assert_eq!(args.items.len(), 2);
1876 assert_eq!(args.items[0].text, "申请测试版");
1877 match &args.items[0].url {
1878 Url::Unresolved(s) => assert_eq!(s, "#青苔正在封闭测试"),
1879 _ => panic!("expected Unresolved"),
1880 }
1881 assert_eq!(args.items[1].text, "文档");
1882 }
1883 _ => panic!("expected Buttons"),
1884 }
1885 }
1886
1887 #[test]
1888 fn extract_shortcodes_does_not_panic_on_arbitrary_cjk_content() {
1889 let md = "# 标题\n\n中文段落,混合 English 单词。\n\n:::buttons\n[申请测试版](#锚点)\n:::\n\n## 二级标题\n\n更多内容。\n";
1895 let result = extract_shortcodes(md);
1896 assert_eq!(result.extracted.len(), 1);
1897 }
1898
1899 #[test]
1900 fn close_fence_with_trailing_text_is_not_recognized() {
1901 let md = ":::buttons\n[a](u)\n::: more text\n[b](v)\n:::\n";
1907 let result = extract_shortcodes(md);
1908 assert_eq!(result.extracted.len(), 1);
1909 match &result.extracted[0].shortcode {
1912 Shortcode::Buttons(args) => {
1913 assert_eq!(args.items.len(), 2);
1914 assert_eq!(args.items[0].text, "a");
1915 assert_eq!(args.items[1].text, "b");
1916 }
1917 _ => panic!("expected Buttons"),
1918 }
1919 }
1920
1921 #[test]
1924 fn extracts_gallery_with_bare_paths() {
1925 let md = ":::gallery\nphoto1.jpg\nphoto2.png\n:::\n";
1926 let result = extract_shortcodes(md);
1927 assert_eq!(result.extracted.len(), 1);
1928 match &result.extracted[0].shortcode {
1929 Shortcode::Gallery(args) => {
1930 assert!(args.columns.is_none());
1931 assert_eq!(args.items.len(), 2);
1932 assert_eq!(args.items[0].alt, "");
1933 match &args.items[0].src {
1934 Url::Unresolved(s) => assert_eq!(s, "photo1.jpg"),
1935 _ => panic!("expected Unresolved"),
1936 }
1937 }
1938 _ => panic!("expected Gallery"),
1939 }
1940 }
1941
1942 #[test]
1943 fn extracts_gallery_with_columns_arg() {
1944 let md = ":::gallery 4\na.jpg\n:::\n";
1945 let result = extract_shortcodes(md);
1946 match &result.extracted[0].shortcode {
1947 Shortcode::Gallery(args) => assert_eq!(args.columns, Some(4)),
1948 _ => panic!("expected Gallery"),
1949 }
1950 }
1951
1952 #[test]
1953 fn extracts_gallery_with_classes() {
1954 let md = ":::gallery 3 {.showcase}\na.jpg\n:::\n";
1955 let result = extract_shortcodes(md);
1956 match &result.extracted[0].shortcode {
1957 Shortcode::Gallery(args) => {
1958 assert_eq!(args.columns, Some(3));
1959 assert_eq!(args.classes, "showcase");
1960 }
1961 _ => panic!("expected Gallery"),
1962 }
1963 }
1964
1965 #[test]
1966 fn extracts_gallery_with_markdown_image_syntax() {
1967 let md = ":::gallery\n\n:::\n";
1968 let result = extract_shortcodes(md);
1969 match &result.extracted[0].shortcode {
1970 Shortcode::Gallery(args) => {
1971 assert_eq!(args.items[0].alt, "A photo");
1972 match &args.items[0].src {
1973 Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
1974 _ => panic!("expected Unresolved"),
1975 }
1976 }
1977 _ => panic!("expected Gallery"),
1978 }
1979 }
1980
1981 #[test]
1982 fn extracts_gallery_with_pipe_attrs() {
1983 let md = ":::gallery\nphoto.jpg|cover top\n:::\n";
1984 let result = extract_shortcodes(md);
1985 match &result.extracted[0].shortcode {
1986 Shortcode::Gallery(args) => {
1987 assert_eq!(args.items[0].attrs, "cover top");
1988 match &args.items[0].src {
1989 Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
1990 _ => panic!("expected Unresolved"),
1991 }
1992 }
1993 _ => panic!("expected Gallery"),
1994 }
1995 }
1996
1997 #[test]
1998 fn gallery_skips_blank_lines() {
1999 let md = ":::gallery\n\na.jpg\n\nb.jpg\n\n:::\n";
2000 let result = extract_shortcodes(md);
2001 match &result.extracted[0].shortcode {
2002 Shortcode::Gallery(args) => assert_eq!(args.items.len(), 2),
2003 _ => panic!("expected Gallery"),
2004 }
2005 }
2006
2007 #[test]
2013 fn extracts_buttons_with_multi_line_attrs() {
2014 let md = ":::buttons {\n .primary\n}\n[Go](go/)\n:::\n";
2017 let result = extract_shortcodes(md);
2018 assert_eq!(result.extracted.len(), 1);
2019 match &result.extracted[0].shortcode {
2020 Shortcode::Buttons(args) => {
2021 assert_eq!(args.classes, "primary");
2022 assert_eq!(args.items.len(), 1);
2023 assert_eq!(args.items[0].text, "Go");
2024 }
2025 _ => panic!("expected Buttons"),
2026 }
2027 }
2028
2029 #[test]
2030 fn extracts_gallery_with_multi_line_attrs() {
2031 let md = ":::gallery {\n .showcase\n}\nphoto.jpg\n:::\n";
2032 let result = extract_shortcodes(md);
2033 match &result.extracted[0].shortcode {
2034 Shortcode::Gallery(args) => {
2035 assert_eq!(args.classes, "showcase");
2036 assert_eq!(args.items.len(), 1);
2037 }
2038 _ => panic!("expected Gallery"),
2039 }
2040 }
2041
2042 #[test]
2043 fn multi_line_attrs_with_quoted_brace_inside() {
2044 let md = ":::buttons {\n .a\n .b\n}\n[Go](go/)\n:::\n";
2047 let result = extract_shortcodes(md);
2048 assert_eq!(result.extracted.len(), 1);
2049 match &result.extracted[0].shortcode {
2050 Shortcode::Buttons(args) => {
2051 assert_eq!(args.classes, "a b");
2053 }
2054 _ => panic!("expected Buttons"),
2055 }
2056 }
2057
2058 #[test]
2061 fn css_region_unnamed_emits_div_wrapper() {
2062 let md = ":::{.tagline}\nA new way to publish.\n:::\n";
2063 let result = extract_shortcodes(md);
2064 assert!(result.extracted.is_empty());
2065 assert!(result
2066 .markdown_with_placeholders
2067 .contains("<div class=\"tagline\">"));
2068 assert!(result
2069 .markdown_with_placeholders
2070 .contains("A new way to publish."));
2071 assert!(result.markdown_with_placeholders.contains("</div>"));
2072 }
2073
2074 #[test]
2075 fn css_region_with_id_only() {
2076 let md = ":::{#intro}\nIntro prose.\n:::\n";
2077 let result = extract_shortcodes(md);
2078 assert!(result
2079 .markdown_with_placeholders
2080 .contains("<div id=\"intro\">"));
2081 }
2082
2083 #[test]
2084 fn css_region_with_classes_and_id() {
2085 let md = ":::{.callout #important}\nWatch out.\n:::\n";
2086 let result = extract_shortcodes(md);
2087 let out = &result.markdown_with_placeholders;
2088 assert!(out.contains("<div"));
2089 assert!(out.contains("class=\"callout\""));
2090 assert!(out.contains("id=\"important\""));
2091 }
2092
2093 #[test]
2094 fn css_region_emits_blank_lines_around_body_for_markdown_processing() {
2095 let md = ":::{.foo}\n# Heading\n:::\n";
2098 let out = extract_shortcodes(md).markdown_with_placeholders;
2099 assert!(out.contains(">\n\n# Heading"));
2101 assert!(out.contains("# Heading\n\n</div>"));
2103 }
2104
2105 #[test]
2106 fn css_region_no_warning_emitted() {
2107 let md = ":::{.foo}\nbody\n:::\n";
2108 assert!(extract_shortcodes(md).warnings.is_empty());
2109 }
2110
2111 #[test]
2114 fn unknown_name_renders_fallback_wrapper() {
2115 let md = ":::nope {.extra}\nbody text\n:::\n";
2116 let result = extract_shortcodes(md);
2117 let out = &result.markdown_with_placeholders;
2118 assert!(out.contains("class=\"moss-unknown-shortcode extra\""));
2119 assert!(out.contains(r#"data-name="nope""#));
2120 assert!(out.contains("body text"));
2121 }
2122
2123 #[test]
2124 fn unknown_name_emits_build_warning() {
2125 let md = ":::nope\n:::\n";
2126 let warnings = extract_shortcodes(md).warnings;
2127 assert_eq!(warnings.len(), 1);
2128 assert!(warnings[0].contains("nope"));
2129 }
2130
2131 #[test]
2132 fn unknown_name_html_escapes_data_name() {
2133 let md = ":::weird-name\nbody\n:::\n";
2138 let out = extract_shortcodes(md).markdown_with_placeholders;
2139 assert!(out.contains(r#"data-name="weird-name""#));
2140 }
2141
2142 #[test]
2146 fn extracts_grid_with_positional_columns() {
2147 let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
2154 let result = extract_shortcodes(md);
2155 assert_eq!(result.extracted.len(), 1);
2156 match &result.extracted[0].shortcode {
2157 Shortcode::Grid(grid) => {
2158 assert_eq!(grid.columns, 2);
2159 assert!(grid.ratio.is_none());
2160 assert_eq!(grid.cells.len(), 2);
2161 assert_paragraph_text(&grid.cells[0], "cell A");
2162 assert_paragraph_text(&grid.cells[1], "cell B");
2163 }
2164 other => panic!("expected Grid, got {other:?}"),
2165 }
2166 }
2167
2168 fn assert_paragraph_text(cell_blocks: &[Block], expected: &str) {
2174 if cell_blocks.is_empty() && expected.is_empty() {
2175 return;
2176 }
2177 let para = match cell_blocks {
2178 [Block::Paragraph(inlines)] => inlines,
2179 other => panic!(
2180 "expected single Paragraph cell with text {expected:?}, got: {other:?}"
2181 ),
2182 };
2183 let mut text = String::new();
2184 for inline in para {
2185 match inline {
2186 super::super::node::Inline::Text(t) => text.push_str(t),
2187 super::super::node::Inline::Code(c) => text.push_str(c),
2188 _ => {}
2189 }
2190 }
2191 assert_eq!(text, expected, "cell text mismatch");
2192 }
2193
2194 #[test]
2195 fn extracts_grid_with_positional_ratio() {
2196 let md = ":::grid 2 1:2\nleft\n---\nright\n:::\n";
2197 let result = extract_shortcodes(md);
2198 match &result.extracted[0].shortcode {
2199 Shortcode::Grid(grid) => {
2200 assert_eq!(grid.columns, 2);
2201 assert_eq!(grid.ratio.as_deref(), Some("1:2"));
2202 }
2203 _ => panic!("expected Grid"),
2204 }
2205 }
2206
2207 #[test]
2208 fn extracts_grid_with_cols_attr_integer() {
2209 let md = ":::grid {cols=3}\nA\n+++\nB\n+++\nC\n:::\n";
2210 let result = extract_shortcodes(md);
2211 match &result.extracted[0].shortcode {
2212 Shortcode::Grid(grid) => {
2213 assert_eq!(grid.columns, 3);
2214 assert_eq!(grid.cells.len(), 3);
2215 assert_paragraph_text(&grid.cells[0], "A");
2216 assert_paragraph_text(&grid.cells[1], "B");
2217 assert_paragraph_text(&grid.cells[2], "C");
2218 }
2219 _ => panic!("expected Grid"),
2220 }
2221 }
2222
2223 #[test]
2224 fn extracts_grid_with_cols_attr_ratio_implies_count() {
2225 let md = ":::grid {cols=1:1:2}\nA\n+++\nB\n+++\nC\n:::\n";
2226 let result = extract_shortcodes(md);
2227 match &result.extracted[0].shortcode {
2228 Shortcode::Grid(grid) => {
2229 assert_eq!(grid.columns, 3, "ratio length implies column count");
2230 assert_eq!(grid.ratio.as_deref(), Some("1:1:2"));
2231 }
2232 _ => panic!("expected Grid"),
2233 }
2234 }
2235
2236 #[test]
2237 fn extracts_grid_accepts_plus_plus_plus_divider() {
2238 let md = ":::grid 2\nA\n+++\nB\n:::\n";
2239 let result = extract_shortcodes(md);
2240 match &result.extracted[0].shortcode {
2241 Shortcode::Grid(grid) => {
2242 assert_eq!(grid.cells.len(), 2);
2243 assert_paragraph_text(&grid.cells[0], "A");
2244 assert_paragraph_text(&grid.cells[1], "B");
2245 }
2246 _ => panic!("expected Grid"),
2247 }
2248 }
2249
2250 #[test]
2251 fn extracts_grid_with_classes() {
2252 let md = ":::grid 3 {.work-cards .featured}\nA\n---\nB\n---\nC\n:::\n";
2253 let result = extract_shortcodes(md);
2254 match &result.extracted[0].shortcode {
2255 Shortcode::Grid(grid) => {
2256 assert_eq!(grid.columns, 3);
2257 assert_eq!(grid.classes, "work-cards featured");
2258 }
2259 _ => panic!("expected Grid"),
2260 }
2261 }
2262
2263 #[test]
2264 fn extracts_grid_single_cell_no_separator() {
2265 let md = ":::grid 1\nonly cell\n:::\n";
2266 let result = extract_shortcodes(md);
2267 match &result.extracted[0].shortcode {
2268 Shortcode::Grid(grid) => {
2269 assert_eq!(grid.columns, 1);
2270 assert_eq!(grid.cells.len(), 1);
2271 assert_paragraph_text(&grid.cells[0], "only cell");
2272 }
2273 _ => panic!("expected Grid"),
2274 }
2275 }
2276
2277 #[test]
2278 fn extracts_grid_with_empty_middle_cell() {
2279 let md = ":::grid 3\nA\n+++\n+++\nC\n:::\n";
2284 let result = extract_shortcodes(md);
2285 match &result.extracted[0].shortcode {
2286 Shortcode::Grid(grid) => {
2287 assert_eq!(grid.cells.len(), 3);
2288 assert_paragraph_text(&grid.cells[0], "A");
2289 assert!(grid.cells[1].is_empty(), "empty cell should have no blocks");
2290 assert_paragraph_text(&grid.cells[2], "C");
2291 }
2292 _ => panic!("expected Grid"),
2293 }
2294 }
2295
2296 #[test]
2297 fn nested_grid_via_arity_is_unsupported_authoring() {
2298 let md = "::::grid 1\n:::grid 2\nA\n+++\nB\n:::\n::::\n";
2310 let result = extract_shortcodes(md);
2311 assert_eq!(result.extracted.len(), 1);
2315 match &result.extracted[0].shortcode {
2316 Shortcode::Grid(outer) => {
2317 assert_eq!(outer.columns, 1);
2318 assert!(outer.cells.len() >= 2,
2321 "outer's body got split by inner's +++, demonstrating the \
2322 unsupported-nesting failure mode");
2323 }
2324 _ => panic!("expected Grid"),
2325 }
2326 }
2327
2328 #[test]
2329 fn extracts_grid_with_compound_link_cell_typed_as_link_card() {
2330 let md = ":::grid 2 {.work-cards}\n[![[poster.jpg]]\n#### Title\nbody](/url)\n+++\n[Card 2](/url2)\n:::\n";
2339 let result = extract_shortcodes(md);
2340 match &result.extracted[0].shortcode {
2341 Shortcode::Grid(grid) => {
2342 assert_eq!(grid.classes, "work-cards");
2343 assert_eq!(grid.cells.len(), 2);
2344 match &grid.cells[0][..] {
2345 [Block::LinkCard { url, children }] => {
2346 match url {
2347 Url::Unresolved(u) => assert_eq!(u, "/url"),
2348 _ => panic!("expected Unresolved /url"),
2349 }
2350 assert!(!children.is_empty(), "compound-link inner blocks empty");
2354 }
2355 other => panic!("expected single LinkCard cell, got {other:?}"),
2356 }
2357 match &grid.cells[1][..] {
2358 [Block::LinkCard { url, .. }] => match url {
2359 Url::Unresolved(u) => assert_eq!(u, "/url2"),
2360 _ => panic!("expected Unresolved /url2"),
2361 },
2362 other => panic!("expected LinkCard for cell[1], got {other:?}"),
2363 }
2364 }
2365 _ => panic!("expected Grid"),
2366 }
2367 }
2368
2369 #[test]
2374 fn toc_now_renders_as_unknown_shortcode() {
2375 let md = ":::toc\n:::\n";
2380 let result = extract_shortcodes(md);
2381 assert!(result.extracted.is_empty(), "toc is no longer typed");
2382 assert_eq!(result.warnings.len(), 1, "unknown-name fallback warning");
2383 assert!(result.warnings[0].contains("toc"));
2384 assert!(result
2385 .markdown_with_placeholders
2386 .contains(r#"data-name="toc""#));
2387 }
2388
2389 #[test]
2392 fn extracts_hero_block_with_no_image() {
2393 let md = ":::hero\n# A House of Daowu\n:::\n";
2394 let result = extract_shortcodes(md);
2395 assert_eq!(result.extracted.len(), 1, "hero should be extracted");
2396 match &result.extracted[0].shortcode {
2397 Shortcode::Hero(args) => {
2398 assert!(args.image.is_none());
2399 assert_eq!(args.overlay_text, "# A House of Daowu");
2400 }
2401 other => panic!("expected Hero, got {other:?}"),
2402 }
2403 assert!(!result.markdown_with_placeholders.contains(":::hero"));
2405 }
2406
2407 #[test]
2408 fn extracts_hero_block_with_wikilink_body_image() {
2409 let md = ":::hero\n![[panorama.jpg]]\n# Welcome\n:::\n";
2410 let result = extract_shortcodes(md);
2411 assert_eq!(result.extracted.len(), 1);
2412 match &result.extracted[0].shortcode {
2413 Shortcode::Hero(args) => {
2414 match &args.image {
2415 Some(Url::Unresolved(s)) => assert_eq!(s, "panorama.jpg"),
2416 other => panic!("expected Unresolved url, got {other:?}"),
2417 }
2418 assert_eq!(args.overlay_text, "# Welcome");
2419 }
2420 other => panic!("expected Hero, got {other:?}"),
2421 }
2422 }
2423
2424 #[test]
2425 fn extracts_hero_block_with_image_attr() {
2426 let md = ":::hero {image=cover.jpg}\n# Title\n:::\n";
2427 let result = extract_shortcodes(md);
2428 match &result.extracted[0].shortcode {
2429 Shortcode::Hero(args) => {
2430 match &args.image {
2431 Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
2432 other => panic!("expected Unresolved, got {other:?}"),
2433 }
2434 assert_eq!(args.overlay_text, "# Title");
2435 }
2436 other => panic!("expected Hero, got {other:?}"),
2437 }
2438 }
2439
2440 #[test]
2441 fn extracts_hero_block_with_image_attr_and_pipe_attrs() {
2442 let md = r#":::hero {image="cover.jpg|contain top"}
2445:::
2446"#;
2447 let result = extract_shortcodes(md);
2448 match &result.extracted[0].shortcode {
2449 Shortcode::Hero(args) => {
2450 match &args.image {
2451 Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
2452 _ => panic!("expected Unresolved"),
2453 }
2454 assert_eq!(args.attrs, "contain top");
2455 }
2456 _ => panic!("expected Hero"),
2457 }
2458 }
2459
2460 #[test]
2461 fn extracts_hero_block_with_classes() {
2462 let md = ":::hero {.full .center}\n# Title\n:::\n";
2463 let result = extract_shortcodes(md);
2464 match &result.extracted[0].shortcode {
2465 Shortcode::Hero(args) => {
2466 assert_eq!(args.classes, "full center");
2467 }
2468 _ => panic!("expected Hero"),
2469 }
2470 }
2471
2472 #[test]
2473 fn extracts_hero_block_with_directive_line_path() {
2474 let md = ":::hero ./assets/header.png\n:::\n";
2480 let result = extract_shortcodes(md);
2481 assert_eq!(result.extracted.len(), 1);
2482 match &result.extracted[0].shortcode {
2483 Shortcode::Hero(args) => match &args.image {
2484 Some(Url::Unresolved(s)) => assert_eq!(s, "./assets/header.png"),
2485 other => panic!("expected Unresolved ./assets/header.png, got {other:?}"),
2486 },
2487 _ => panic!("expected Hero"),
2488 }
2489 }
2490
2491 #[test]
2492 fn extracts_hero_block_with_directive_line_path_and_pipe_attrs() {
2493 let md = ":::hero ./bg.jpg|contain top\n:::\n";
2494 let result = extract_shortcodes(md);
2495 match &result.extracted[0].shortcode {
2496 Shortcode::Hero(args) => {
2497 match &args.image {
2498 Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
2499 _ => panic!("expected Unresolved"),
2500 }
2501 assert_eq!(args.attrs, "contain top");
2502 }
2503 _ => panic!("expected Hero"),
2504 }
2505 }
2506
2507 #[test]
2508 fn extracts_hero_block_with_directive_line_path_and_classes() {
2509 let md = ":::hero ./bg.jpg {.landing}\n# Welcome\n:::\n";
2512 let result = extract_shortcodes(md);
2513 match &result.extracted[0].shortcode {
2514 Shortcode::Hero(args) => {
2515 match &args.image {
2516 Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
2517 _ => panic!("expected Unresolved"),
2518 }
2519 assert_eq!(args.classes, "landing");
2520 assert_eq!(args.overlay_text, "# Welcome");
2521 }
2522 _ => panic!("expected Hero"),
2523 }
2524 }
2525
2526 #[test]
2529 fn nested_css_region_outer_closes_at_first_inner_close() {
2530 let md = ":::{.outer}\n:::{.inner}\nbody\n:::\n:::\n";
2539 let result = extract_shortcodes(md);
2540 let out = &result.markdown_with_placeholders;
2541 assert!(out.contains("<div class=\"outer\""));
2543 assert!(out.contains(":::{.inner}"));
2546 }
2547
2548 #[test]
2549 fn nested_css_region_higher_arity_outer_recurses_into_inner() {
2550 let md = "::::{.outer}\n:::{.inner}\nbody\n:::\n::::\n";
2555 let result = extract_shortcodes(md);
2556 let out = &result.markdown_with_placeholders;
2557 assert!(out.contains("<div class=\"outer\""));
2558 assert!(out.contains("<div class=\"inner\""));
2559 assert!(!out.contains(":::{.inner}"));
2561 }
2562
2563 #[test]
2564 fn css_region_containing_typed_subscribe_is_not_recursively_extracted() {
2565 let md = ":::{.wrapper}\n:::subscribe\n:::\n:::\n";
2568 let result = extract_shortcodes(md);
2569 assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
2573 assert!(result.extracted.is_empty());
2575 }
2576
2577 #[test]
2578 fn higher_arity_wrapper_recursively_extracts_typed_subscribe() {
2579 let md = "::::{.wrapper}\n:::subscribe\n:::\n::::\n";
2585 let result = extract_shortcodes(md);
2586 assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
2587 assert_eq!(result.extracted.len(), 1);
2588 match &result.extracted[0].shortcode {
2589 Shortcode::Subscribe(_) => {}
2590 _ => panic!("expected Subscribe"),
2591 }
2592 assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
2595 }
2596
2597 #[test]
2598 fn lower_arity_outer_wraps_higher_arity_typed_inner() {
2599 let md = ":::{.support-band}\n## Title\n\n::::buttons {.inverted}\n[Support Us](/support)\n::::\n*footnote*\n:::\n";
2605 let result = extract_shortcodes(md);
2606 let out = &result.markdown_with_placeholders;
2607 assert!(out.contains("<div class=\"support-band\""));
2609 assert_eq!(result.extracted.len(), 1);
2611 match &result.extracted[0].shortcode {
2612 Shortcode::Buttons(args) => {
2613 assert_eq!(args.items.len(), 1);
2614 }
2615 _ => panic!("expected Buttons"),
2616 }
2617 assert!(!out.contains("::::buttons"));
2619 assert!(!out.contains("::::"));
2620 }
2621
2622 #[test]
2623 fn lower_arity_outer_wraps_grid_with_buttons_in_cell() {
2624 let md = "::: {.hero-split}\n::::grid 2 {.no-cards}\nleft\n+++\nright\n::::\n:::\n";
2631 let result = extract_shortcodes(md);
2632 let out = &result.markdown_with_placeholders;
2633 assert!(out.contains("<div class=\"hero-split\""));
2635 assert_eq!(result.extracted.len(), 1);
2637 match &result.extracted[0].shortcode {
2638 Shortcode::Grid(_) => {}
2639 _ => panic!("expected Grid"),
2640 }
2641 assert!(!out.contains("::::grid"));
2643 }
2644
2645 #[test]
2646 fn unknown_name_body_recursively_extracts_typed_inner() {
2647 let md = ":::buttosn\n::::buttons\n[a](u)\n::::\n:::\n";
2653 let result = extract_shortcodes(md);
2654 let out = &result.markdown_with_placeholders;
2655 assert!(out.contains("data-name=\"buttosn\""));
2657 assert_eq!(result.extracted.len(), 1);
2659 match &result.extracted[0].shortcode {
2660 Shortcode::Buttons(_) => {}
2661 _ => panic!("expected Buttons"),
2662 }
2663 }
2664
2665 #[test]
2666 fn unknown_name_with_plus_plus_plus_in_body_passes_through() {
2667 let md = ":::buttosn\n[a](u)\n+++\n[b](v)\n:::\n";
2673 let result = extract_shortcodes(md);
2674 let out = &result.markdown_with_placeholders;
2675 assert!(out.contains(r#"data-name="buttosn""#));
2676 assert!(out.contains("[a](u)"));
2677 assert!(out.contains("+++"));
2678 assert!(out.contains("[b](v)"));
2679 }
2680
2681 #[test]
2682 fn parse_shortcode_opener_recognizes_empty_name_with_attrs() {
2683 assert_eq!(
2684 parse_shortcode_opener(":::{.tagline}"),
2685 Some((3, "", "{.tagline}"))
2686 );
2687 }
2688
2689 #[test]
2690 fn parse_shortcode_opener_rejects_just_colons() {
2691 assert!(parse_shortcode_opener(":::").is_none());
2692 assert!(parse_shortcode_opener("::: ").is_none());
2693 }
2694
2695 #[test]
2696 fn unclosed_multi_line_attrs_block_emits_verbatim() {
2697 let md = ":::buttons {\n .primary\n[Go](go/)\n:::\n";
2700 let result = extract_shortcodes(md);
2701 assert!(result.extracted.is_empty() || matches!(result.extracted[0].shortcode, Shortcode::Buttons(_)));
2706 }
2708
2709 #[test]
2712 fn grid_legacy_dash_emits_deprecation_warning() {
2713 let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
2714 let result = extract_shortcodes(md);
2715 assert_eq!(result.warnings.len(), 1);
2716 assert!(result.warnings[0].contains("deprecated"));
2717 assert!(result.warnings[0].contains("+++"));
2718 }
2719
2720 #[test]
2721 fn grid_plus_plus_plus_no_deprecation_warning() {
2722 let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
2723 let result = extract_shortcodes(md);
2724 assert!(result.warnings.is_empty());
2725 }
2726
2727 #[test]
2728 fn hero_priority3_body_image_emits_deprecation_warning() {
2729 let md = ":::hero\nphoto.jpg\n# Title\n:::\n";
2730 let result = extract_shortcodes(md);
2731 assert_eq!(result.warnings.len(), 1);
2732 assert!(result.warnings[0].contains("deprecated"));
2733 assert!(result.warnings[0].contains("image="));
2734 }
2735
2736 #[test]
2737 fn hero_explicit_image_attr_no_deprecation_warning() {
2738 let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
2739 let result = extract_shortcodes(md);
2740 assert!(result.warnings.is_empty());
2741 }
2742
2743 fn first_extracted(md: &str) -> Shortcode {
2751 let result = extract_shortcodes(md);
2752 result
2753 .extracted
2754 .into_iter()
2755 .next()
2756 .expect("at least one shortcode")
2757 .shortcode
2758 }
2759
2760 #[test]
2761 fn hero_with_full_flag_sets_width_screen() {
2762 let md = ":::hero {image=photo.jpg full}\n# Title\n:::\n";
2763 match first_extracted(md) {
2764 Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
2765 other => panic!("expected Hero, got {other:?}"),
2766 }
2767 }
2768
2769 #[test]
2770 fn hero_with_screen_flag_sets_width_screen() {
2771 let md = ":::hero {image=photo.jpg screen}\n# Title\n:::\n";
2772 match first_extracted(md) {
2773 Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
2774 other => panic!("expected Hero, got {other:?}"),
2775 }
2776 }
2777
2778 #[test]
2779 fn hero_with_wide_flag_sets_width_wide() {
2780 let md = ":::hero {image=photo.jpg wide}\n# Title\n:::\n";
2781 match first_extracted(md) {
2782 Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("wide")),
2783 other => panic!("expected Hero, got {other:?}"),
2784 }
2785 }
2786
2787 #[test]
2788 fn hero_without_width_flag_leaves_width_none() {
2789 let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
2790 match first_extracted(md) {
2791 Shortcode::Hero(h) => assert!(h.width.is_none(), "got {:?}", h.width),
2792 other => panic!("expected Hero, got {other:?}"),
2793 }
2794 }
2795
2796 #[test]
2797 fn hero_mobile_overlay_attr_is_parsed() {
2798 let md = ":::hero {image=hero.jpg mobile=overlay}\n# Title\n:::\n";
2799 let result = extract_shortcodes(md);
2800 assert_eq!(result.extracted.len(), 1);
2801 match &result.extracted[0].shortcode {
2802 Shortcode::Hero(args) => {
2803 assert_eq!(args.mobile.as_deref(), Some("overlay"));
2804 }
2805 other => panic!("expected Hero, got {other:?}"),
2806 }
2807 }
2808
2809 #[test]
2810 fn hero_without_mobile_attr_has_none() {
2811 let md = ":::hero {image=hero.jpg}\n# Title\n:::\n";
2812 let result = extract_shortcodes(md);
2813 match &result.extracted[0].shortcode {
2814 Shortcode::Hero(args) => {
2815 assert!(args.mobile.is_none());
2816 }
2817 other => panic!("expected Hero, got {other:?}"),
2818 }
2819 }
2820
2821 #[test]
2822 fn hero_mobile_overlay_with_body_image_fallback() {
2823 let md = ":::hero {mobile=overlay}\n![[bg.jpg]]\n# Title\n:::\n";
2824 let result = extract_shortcodes(md);
2825 match &result.extracted[0].shortcode {
2826 Shortcode::Hero(args) => {
2827 assert_eq!(args.mobile.as_deref(), Some("overlay"));
2828 assert!(args.image.is_some());
2829 }
2830 other => panic!("expected Hero, got {other:?}"),
2831 }
2832 }
2833
2834 #[test]
2835 fn hero_unknown_mobile_value_emits_warning() {
2836 let md = ":::hero {image=hero.jpg mobile=fullscreen}\n# Title\n:::\n";
2837 let result = extract_shortcodes(md);
2838 assert!(
2839 result.warnings.iter().any(|w| w.contains("unrecognized") && w.contains("fullscreen")),
2840 "expected warning for unknown mobile value, got: {:?}",
2841 result.warnings,
2842 );
2843 assert_eq!(result.extracted.len(), 1);
2845 }
2846
2847 #[test]
2848 fn placeholder_preserves_block_line_count_for_source_line_accuracy() {
2849 let md = "# Title\n\n:::grid 3\n[\n\n](/x)\n+++\n[\n\n](/y)\n:::\n\n## After\n";
2854 let input_lines = md.lines().count();
2855 let result = extract_shortcodes(md);
2856 assert_eq!(
2857 result.markdown_with_placeholders.lines().count(),
2858 input_lines,
2859 "placeholder must preserve the block's line count; got:\n{}",
2860 result.markdown_with_placeholders
2861 );
2862 let after_line = result
2864 .markdown_with_placeholders
2865 .lines()
2866 .position(|l| l.contains("## After"))
2867 .map(|p| p + 1);
2868 assert_eq!(after_line, Some(13), "## After should stay on line 13");
2869 }
2870
2871 #[test]
2872 fn gallery_with_page_flag_sets_width_page() {
2873 let md = ":::gallery 3 {page}\nphoto.jpg\n:::\n";
2874 match first_extracted(md) {
2875 Shortcode::Gallery(g) => assert_eq!(g.width.as_deref(), Some("page")),
2876 other => panic!("expected Gallery, got {other:?}"),
2877 }
2878 }
2879
2880 #[test]
2881 fn gallery_without_width_flag_leaves_width_none() {
2882 let md = ":::gallery 3\nphoto.jpg\n:::\n";
2883 match first_extracted(md) {
2884 Shortcode::Gallery(g) => assert!(g.width.is_none()),
2885 other => panic!("expected Gallery, got {other:?}"),
2886 }
2887 }
2888
2889 #[test]
2890 fn grid_with_wide_flag_sets_width_wide() {
2891 let md = ":::grid {cols=2 wide}\ncell A\n+++\ncell B\n:::\n";
2892 match first_extracted(md) {
2893 Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("wide")),
2894 other => panic!("expected Grid, got {other:?}"),
2895 }
2896 }
2897
2898 #[test]
2899 fn grid_with_full_flag_normalizes_to_screen() {
2900 let md = ":::grid {cols=2 full}\ncell A\n+++\ncell B\n:::\n";
2901 match first_extracted(md) {
2902 Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("screen")),
2903 other => panic!("expected Grid, got {other:?}"),
2904 }
2905 }
2906
2907 #[test]
2908 fn grid_without_width_flag_leaves_width_none() {
2909 let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
2910 match first_extracted(md) {
2911 Shortcode::Grid(g) => assert!(g.width.is_none()),
2912 other => panic!("expected Grid, got {other:?}"),
2913 }
2914 }
2915
2916 #[test]
2919 fn parses_recent_with_since_and_count() {
2920 let (sc, warns) = parse_shortcode_block(
2921 "recent",
2922 r#"{since="2026-04-01" count="5"}"#,
2923 "",
2924 &ParseConfig::default(),
2925 );
2926 assert!(warns.is_empty());
2927 match sc.expect("expected Some(Shortcode)") {
2928 Shortcode::Recent(args) => {
2929 assert_eq!(args.since.as_deref(), Some("2026-04-01"));
2930 assert_eq!(args.count, Some(5));
2931 assert!(args.last.is_none());
2932 assert!(args.fallback_markdown.is_empty());
2933 }
2934 other => panic!("expected Recent, got {other:?}"),
2935 }
2936 }
2937
2938 #[test]
2939 fn parses_recent_with_last_window() {
2940 let (sc, _) = parse_shortcode_block("recent", r#"{last="month"}"#, "", &ParseConfig::default());
2941 match sc.expect("expected Some(Shortcode)") {
2942 Shortcode::Recent(args) => {
2943 assert_eq!(args.last.as_deref(), Some("month"));
2944 assert!(args.since.is_none());
2945 assert!(args.count.is_none());
2946 }
2947 other => panic!("expected Recent, got {other:?}"),
2948 }
2949 }
2950
2951 #[test]
2952 fn captures_recent_body_as_fallback_markdown() {
2953 let body = "No posts yet. [Follow along](/).";
2954 let (sc, _) = parse_shortcode_block("recent", "", body, &ParseConfig::default());
2955 match sc.expect("expected Some(Shortcode)") {
2956 Shortcode::Recent(args) => {
2957 assert_eq!(args.fallback_markdown, body);
2958 }
2959 other => panic!("expected Recent, got {other:?}"),
2960 }
2961 }
2962
2963 #[test]
2964 fn recent_with_no_args_yields_all_none() {
2965 let (sc, warns) = parse_shortcode_block("recent", "", "", &ParseConfig::default());
2966 assert!(warns.is_empty());
2967 match sc.expect("expected Some(Shortcode)") {
2968 Shortcode::Recent(args) => {
2969 assert!(args.since.is_none());
2970 assert!(args.last.is_none());
2971 assert!(args.count.is_none());
2972 assert!(args.fallback_markdown.is_empty());
2973 }
2974 other => panic!("expected Recent, got {other:?}"),
2975 }
2976 }
2977
2978 #[test]
2979 fn parses_recent_with_all_three_attrs() {
2980 let (sc, warns) = parse_shortcode_block(
2981 "recent",
2982 r#"{since="2026-01-01" last="month" count="3"}"#,
2983 "",
2984 &ParseConfig::default(),
2985 );
2986 assert!(warns.is_empty());
2987 match sc.expect("expected Some(Shortcode)") {
2988 Shortcode::Recent(args) => {
2989 assert_eq!(args.since.as_deref(), Some("2026-01-01"));
2990 assert_eq!(args.last.as_deref(), Some("month"));
2991 assert_eq!(args.count, Some(3));
2992 }
2993 other => panic!("expected Recent, got {other:?}"),
2994 }
2995 }
2996
2997 #[test]
2998 fn recent_with_malformed_count_yields_none_count() {
2999 let (sc, _) = parse_shortcode_block("recent", r#"{count="lots"}"#, "", &ParseConfig::default());
3003 match sc.expect("expected Some(Shortcode)") {
3004 Shortcode::Recent(args) => assert!(args.count.is_none()),
3005 other => panic!("expected Recent, got {other:?}"),
3006 }
3007 }
3008
3009 #[test]
3010 fn recent_body_is_trimmed() {
3011 let (sc, _) = parse_shortcode_block("recent", "", "\n hello world \n\n", &ParseConfig::default());
3014 match sc.expect("expected Some(Shortcode)") {
3015 Shortcode::Recent(args) => assert_eq!(args.fallback_markdown, "hello world"),
3016 other => panic!("expected Recent, got {other:?}"),
3017 }
3018 }
3019
3020 #[test]
3023 fn parses_apply_directive() {
3024 use super::super::shortcode::ShortcodeKind;
3025 use super::super::visit::has_shortcode_recursive;
3026 let doc = crate::ast::parse(":::apply\n:::\n");
3027 assert!(
3028 has_shortcode_recursive(&doc, ShortcodeKind::Apply),
3029 "expected an Apply shortcode"
3030 );
3031 }
3032
3033 #[test]
3034 fn apply_parse_bare_has_none_overrides() {
3035 let (sc, warns) = parse_shortcode_block("apply", "", "", &ParseConfig::default());
3036 assert!(warns.is_empty());
3037 match sc.expect("expected Some(Shortcode)") {
3038 Shortcode::Apply(args) => {
3039 assert!(args.placeholder.is_none());
3040 assert!(args.button.is_none());
3041 }
3042 other => panic!("expected Apply, got {other:?}"),
3043 }
3044 }
3045
3046 #[test]
3047 fn apply_parse_with_overrides() {
3048 let (sc, _) = parse_shortcode_block("apply", r#"{placeholder="email" button="申请"}"#, "", &ParseConfig::default());
3049 match sc.expect("expected Some(Shortcode)") {
3050 Shortcode::Apply(args) => {
3051 assert_eq!(args.placeholder.as_deref(), Some("email"));
3052 assert_eq!(args.button.as_deref(), Some("申请"));
3053 }
3054 other => panic!("expected Apply, got {other:?}"),
3055 }
3056 }
3057
3058 #[test]
3059 fn extracts_recent_end_to_end_with_sentinel() {
3060 let md = ":::recent {since=\"2026-04-01\" count=\"5\"}\nNo posts yet.\n:::\n";
3064 let result = extract_shortcodes(md);
3065 assert_eq!(result.extracted.len(), 1);
3066 match &result.extracted[0].shortcode {
3067 Shortcode::Recent(args) => {
3068 assert_eq!(args.since.as_deref(), Some("2026-04-01"));
3069 assert_eq!(args.count, Some(5));
3070 assert_eq!(args.fallback_markdown, "No posts yet.");
3071 }
3072 other => panic!("expected Recent, got {other:?}"),
3073 }
3074 assert!(!result.markdown_with_placeholders.contains(":::recent"));
3075 assert!(result
3076 .markdown_with_placeholders
3077 .contains(&placeholder_for(&result.nonce, 0)));
3078 }
3079}