1use super::attrs::gather_multi_line_attrs;
22use super::cells::split_cells;
23use super::node::Block;
24use super::shortcode::{
25 ApplyShortcode, ButtonItem, ButtonsShortcode, GalleryItem, GalleryShortcode, GridShortcode,
26 HeroShortcode, 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", "apply"];
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 if let Some(ref v) = sc.mobile {
95 if v != "overlay" {
96 warns.push(format!(
97 "shortcode `:::hero` has unrecognized `mobile={v}`. \
98 Only `mobile=overlay` is recognized. The attribute is ignored."
99 ));
100 }
101 }
102 (Some(Shortcode::Hero(sc)), warns)
103 }
104 "grid" => {
105 let (sc, legacy) = parse_grid(args, body);
106 let mut warns = vec![];
107 if legacy {
108 warns.push(
109 "shortcode `:::grid` uses `---` cell dividers (deprecated). Migrate to `+++`.\n\
110 `---` support will be removed in a future release."
111 .to_string(),
112 );
113 }
114 (Some(Shortcode::Grid(sc)), warns)
115 }
116 "recent" => (Some(Shortcode::Recent(parse_recent_args(args, body))), vec![]),
117 "apply" => (Some(Shortcode::Apply(parse_apply_args(args))), vec![]),
118 _ => (None, vec![]),
119 }
120}
121
122pub fn parse_recent_args(args: &str, body: &str) -> RecentShortcode {
135 let attrs = super::attrs::parse_attrs(args).unwrap_or_default();
136 RecentShortcode {
137 since: attrs.get("since").map(str::to_string),
138 last: attrs.get("last").map(str::to_string),
139 count: attrs.get("count").and_then(|v| v.parse::<u32>().ok()),
140 fallback_markdown: body.trim().to_string(),
141 }
142}
143
144fn parse_grid(args: &str, body: &str) -> (GridShortcode, bool) {
161 let trimmed = args.trim();
162 let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed.find('{') {
163 #[allow(clippy::string_slice)]
165 (trimmed[..pos].trim(), &trimmed[pos..])
166 } else {
167 (trimmed, "")
168 };
169
170 let parsed = if attr_block.is_empty() {
171 Default::default()
172 } else {
173 super::attrs::parse_attrs(attr_block).unwrap_or_default()
174 };
175 let classes = parsed.class_string();
176 let width = parsed.width.map(str::to_string);
177
178 let mut columns: u32 = 1;
179 let mut ratio: Option<String> = None;
180
181 if let Some(cols_value) = parsed.get("cols") {
182 if cols_value.contains(':') {
183 ratio = Some(cols_value.to_string());
184 columns = cols_value.split(':').count() as u32;
185 } else if let Ok(n) = cols_value.parse::<u32>() {
186 columns = n.max(1);
187 }
188 } else {
189 let parts: Vec<&str> = positional.split_whitespace().collect();
191 if let Some(first) = parts.first() {
192 if first.contains(':') {
193 ratio = Some(first.to_string());
194 columns = first.split(':').count() as u32;
195 } else if let Ok(n) = first.parse::<u32>() {
196 columns = n.max(1);
197 if let Some(second) = parts.get(1) {
198 if second.contains(':') {
199 ratio = Some(second.to_string());
200 }
201 }
202 }
203 }
204 }
205
206 let (raw_cells, found_legacy_dash) = split_grid_cells(body);
207
208 let cells: Vec<Vec<Block>> = raw_cells
223 .iter()
224 .map(|raw| parse_cell_to_blocks(raw))
225 .collect();
226
227 (
228 GridShortcode {
229 columns,
230 ratio,
231 classes,
232 cells,
233 width,
234 },
235 found_legacy_dash,
236 )
237}
238
239fn parse_cell_to_blocks(raw: &str) -> Vec<Block> {
247 if let Some((url, inner)) = detect_compound_link(raw) {
248 let inner_trimmed = inner.trim();
249 let inner_is_plain_text = !inner_trimmed.contains('!')
265 && !inner_trimmed.contains('[')
266 && !inner_trimmed.contains('\n');
267 let is_external = url.starts_with("http://") || url.starts_with("https://");
268 if inner_is_plain_text && is_external {
269 let linkified = format!("[{}]({})", inner_trimmed, url);
272 return super::parser::parse(&linkified).blocks;
273 }
274 let inner_doc = super::parser::parse(inner_trimmed);
275 return vec![Block::LinkCard {
276 url: Url::unresolved(url),
277 children: inner_doc.blocks,
278 }];
279 }
280 if let Some(url) = detect_bare_url_cell(raw) {
293 let linkified = format!("[]({})", url);
294 let doc = super::parser::parse(&linkified);
295 return doc.blocks;
296 }
297 let doc = super::parser::parse(raw);
298 doc.blocks
299}
300
301fn detect_bare_url_cell(cell_text: &str) -> Option<String> {
309 let trimmed = cell_text.trim();
310 if trimmed.is_empty() {
311 return None;
312 }
313 if trimmed.lines().count() > 1 {
314 return None;
315 }
316 if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
317 return None;
318 }
319 if trimmed.chars().any(char::is_whitespace) {
320 return None;
321 }
322 Some(trimmed.to_string())
323}
324
325pub(super) fn detect_compound_link(cell_text: &str) -> Option<(String, String)> {
349 let stripped = cell_text.trim();
350
351 if !stripped.starts_with('[') {
352 return None;
353 }
354 if !stripped.ends_with(')') {
355 return None;
356 }
357 if stripped.len() > 1 && stripped.as_bytes()[1] == b'`' {
358 return None;
359 }
360
361 for line in stripped.lines() {
362 let t = line.trim();
363 if t.starts_with("```") || t.starts_with("~~~") {
364 return None;
365 }
366 }
367
368 let bytes = stripped.as_bytes();
369
370 let mut i: usize = 1;
372 let mut depth: usize = 1;
373 let mut outer_close: Option<usize> = None;
374
375 while i < bytes.len() {
376 match bytes[i] {
377 b'\\' => {
378 i += 2;
379 continue;
380 }
381 b'`' => {
382 let tick_start = i;
383 while i < bytes.len() && bytes[i] == b'`' {
384 i += 1;
385 }
386 let fence_len = i - tick_start;
387 'code_scan: while i < bytes.len() {
388 if bytes[i] == b'`' {
389 let close_start = i;
390 while i < bytes.len() && bytes[i] == b'`' {
391 i += 1;
392 }
393 if i - close_start == fence_len {
394 break 'code_scan;
395 }
396 } else {
397 i += 1;
398 }
399 }
400 continue;
401 }
402 b'[' => {
403 depth += 1;
404 }
405 b']' => {
406 depth -= 1;
407 if depth == 0 {
408 outer_close = Some(i);
409 break;
410 }
411 }
412 _ => {}
413 }
414 i += 1;
415 }
416
417 let close_bracket = outer_close?;
418
419 if bytes.get(close_bracket + 1) != Some(&b'(') {
420 return None;
421 }
422
423 let mut j = close_bracket + 2;
425 let mut pdepth: usize = 1;
426 let mut paren_close: Option<usize> = None;
427
428 while j < bytes.len() {
429 match bytes[j] {
430 b'\\' => {
431 j += 2;
432 continue;
433 }
434 b'(' => pdepth += 1,
435 b')' => {
436 pdepth -= 1;
437 if pdepth == 0 {
438 paren_close = Some(j);
439 break;
440 }
441 }
442 _ => {}
443 }
444 j += 1;
445 }
446
447 let close_paren = paren_close?;
448
449 let tail = &stripped[close_paren + 1..];
451 if !tail.chars().all(|c| c.is_whitespace()) {
452 return None;
453 }
454
455 let inner = &stripped[1..close_bracket];
457 if inner.trim().is_empty() {
458 return None;
459 }
460
461 {
463 let inner_bytes = inner.as_bytes();
464 let mut k: usize = 0;
465 let mut image_stack: Vec<bool> = Vec::new();
466
467 while k < inner_bytes.len() {
468 match inner_bytes[k] {
469 b'\\' => {
470 k += 2;
471 continue;
472 }
473 b'`' => {
474 let tick_start = k;
475 while k < inner_bytes.len() && inner_bytes[k] == b'`' {
476 k += 1;
477 }
478 let fence_len = k - tick_start;
479 'inner_code: while k < inner_bytes.len() {
480 if inner_bytes[k] == b'`' {
481 let cs = k;
482 while k < inner_bytes.len() && inner_bytes[k] == b'`' {
483 k += 1;
484 }
485 if k - cs == fence_len {
486 break 'inner_code;
487 }
488 } else {
489 k += 1;
490 }
491 }
492 continue;
493 }
494 b'[' => {
495 let preceded_by_bang = k > 0 && inner_bytes[k - 1] == b'!';
496 image_stack.push(preceded_by_bang);
497 }
498 b']' => {
499 if let Some(is_image) = image_stack.pop() {
500 if image_stack.is_empty() && inner_bytes.get(k + 1) == Some(&b'(') {
501 if !is_image {
502 return None;
503 }
504 }
505 }
506 }
507 _ => {}
508 }
509 k += 1;
510 }
511 }
512
513 let url = &stripped[close_bracket + 2..close_paren];
514 Some((url.to_string(), inner.to_string()))
515}
516
517fn split_grid_cells(body: &str) -> (Vec<String>, bool) {
528 if body.is_empty() {
529 return (vec![String::new()], false);
530 }
531 let mut cells = Vec::new();
532 let mut current = String::new();
533 let mut first_line_in_cell = true;
534 let mut found_legacy_dash = false;
535
536 for line in body.split_inclusive('\n') {
537 let content_no_eol = line.strip_suffix('\n').unwrap_or(line);
538 let trimmed = content_no_eol.trim();
539 if trimmed == "+++" || trimmed == "---" {
540 if trimmed == "---" {
541 found_legacy_dash = true;
542 }
543 if let Some(stripped) = current.strip_suffix('\n') {
544 current.truncate(stripped.len());
545 }
546 cells.push(std::mem::take(&mut current));
547 first_line_in_cell = true;
548 continue;
549 }
550 if first_line_in_cell {
551 first_line_in_cell = false;
552 if trimmed.is_empty() {
553 continue;
554 }
555 }
556 current.push_str(line);
557 }
558 if let Some(stripped) = current.strip_suffix('\n') {
559 current.truncate(stripped.len());
560 }
561 cells.push(current);
562 (cells, found_legacy_dash)
563}
564
565fn parse_hero(args: &str, body: &str) -> (HeroShortcode, bool) {
583 let trimmed_args = args.trim();
584
585 let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed_args.find('{') {
588 #[allow(clippy::string_slice)]
590 (trimmed_args[..pos].trim(), &trimmed_args[pos..])
591 } else {
592 (trimmed_args, "")
593 };
594
595 let parsed = if attr_block.is_empty() {
597 Default::default()
598 } else {
599 super::attrs::parse_attrs(attr_block).unwrap_or_default()
600 };
601 let classes = parsed.class_string();
602 let width = parsed.width.map(str::to_string);
603 let mobile = parsed.get("mobile").map(str::to_string);
604
605 if let Some(image_value) = parsed.get("image") {
607 let (path, attrs_str) = crate::media::split_pipe(image_value);
608 let overlay_text = body.trim().to_string();
609 let overlay = parse_overlay_to_blocks(&overlay_text);
610 return (
611 HeroShortcode {
612 image: if path.trim().is_empty() {
613 None
614 } else {
615 Some(Url::unresolved(path.trim().to_string()))
616 },
617 attrs: attrs_str.to_string(),
618 classes,
619 overlay,
620 overlay_text,
621 width,
622 mobile,
623 },
624 false,
625 );
626 }
627
628 if !positional.is_empty() {
632 let (path, attrs_str) = crate::media::split_pipe(positional);
633 let overlay_text = body.trim().to_string();
634 let overlay = parse_overlay_to_blocks(&overlay_text);
635 return (
636 HeroShortcode {
637 image: if path.trim().is_empty() {
638 None
639 } else {
640 Some(Url::unresolved(path.trim().to_string()))
641 },
642 attrs: attrs_str.to_string(),
643 classes,
644 overlay,
645 overlay_text,
646 width,
647 mobile,
648 },
649 false,
650 );
651 }
652
653 let mut overlay_lines: Vec<&str> = Vec::new();
655 let mut image_path: Option<String> = None;
656 let mut image_attrs = String::new();
657 let mut found_image = false;
658 let mut used_priority_3 = false;
659 for line in body.lines() {
660 if !found_image && !line.trim().is_empty() {
661 if let Some((path, attrs_str)) = parse_hero_media_line(line) {
662 image_path = Some(path);
663 image_attrs = attrs_str;
664 found_image = true;
665 used_priority_3 = true;
666 continue;
667 }
668 found_image = true;
670 }
671 overlay_lines.push(line);
672 }
673 let overlay_text = overlay_lines.join("\n").trim().to_string();
674 let overlay = parse_overlay_to_blocks(&overlay_text);
675 (
676 HeroShortcode {
677 image: image_path.map(Url::unresolved),
678 attrs: image_attrs,
679 classes,
680 overlay,
681 overlay_text,
682 width,
683 mobile,
684 },
685 used_priority_3,
686 )
687}
688
689fn parse_overlay_to_blocks(raw: &str) -> Vec<Block> {
696 if raw.is_empty() {
697 return Vec::new();
698 }
699 let doc = super::parser::parse(raw);
700 doc.blocks
701}
702
703const HERO_MEDIA_EXTENSIONS: &[&str] = &[
705 "jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov",
706];
707
708fn is_bare_hero_media(s: &str) -> bool {
709 let (path_part, _) = crate::media::split_pipe(s);
710 let path = path_part.trim();
711 path.rfind('.')
712 .map(|dot| {
713 #[allow(clippy::string_slice)]
716 let ext = &path[dot + 1..];
717 HERO_MEDIA_EXTENSIONS
718 .iter()
719 .any(|e| e.eq_ignore_ascii_case(ext))
720 })
721 .unwrap_or(false)
722}
723
724fn parse_hero_media_line(line: &str) -> Option<(String, String)> {
726 let trimmed = line.trim();
727
728 if let Some(inner) = trimmed
730 .strip_prefix("![[")
731 .and_then(|s| s.strip_suffix("]]"))
732 {
733 let (path, attrs_str) = crate::media::split_pipe(inner);
734 return Some((path.trim().to_string(), attrs_str.to_string()));
735 }
736
737 if trimmed.starts_with(" {
740 if trimmed.ends_with(')') {
741 #[allow(clippy::string_slice)]
745 let inner = &trimmed[paren_open + 2..trimmed.len() - 1];
746 let (path, attrs_str) = crate::media::split_pipe(inner);
747 return Some((path.trim().to_string(), attrs_str.to_string()));
748 }
749 }
750 }
751
752 if is_bare_hero_media(trimmed) {
754 let (path, attrs_str) = crate::media::split_pipe(trimmed);
755 return Some((path.trim().to_string(), attrs_str.to_string()));
756 }
757
758 None
759}
760
761fn parse_gallery_body(args: &str, body: &str) -> GalleryShortcode {
762 let (positional, classes, width) = split_positional_classes_and_width(args);
766 let columns = if positional.is_empty() {
767 None
768 } else {
769 positional.parse::<u32>().ok()
770 };
771 let mut items: Vec<GalleryItem> = Vec::new();
772 for line in body.lines() {
773 let trimmed = line.trim();
774 if trimmed.is_empty() {
775 continue;
776 }
777 let (src_raw, attrs) = split_pipe(trimmed);
780 let (src_url, alt) = match parse_markdown_image(src_raw) {
781 Some((alt, path)) => (path, alt),
782 None => (src_raw.trim().to_string(), String::new()),
783 };
784 items.push(GalleryItem {
785 src: Url::unresolved(src_url),
786 alt,
787 attrs: attrs.to_string(),
788 });
789 }
790 GalleryShortcode {
791 columns,
792 classes,
793 items,
794 width,
795 }
796}
797
798fn split_positional_classes_and_width(args: &str) -> (String, String, Option<String>) {
806 let trimmed = args.trim();
807 if let Some(brace_start) = trimmed.find('{') {
808 #[allow(clippy::string_slice)]
809 let after_open = &trimmed[brace_start..];
810 if let Some(brace_end) = after_open.find('}') {
811 #[allow(clippy::string_slice)]
812 let positional = trimmed[..brace_start].trim().to_string();
813 #[allow(clippy::string_slice)]
814 let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
815 if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
816 return (
817 positional,
818 parsed.class_string(),
819 parsed.width.map(str::to_string),
820 );
821 }
822 #[allow(clippy::string_slice)]
827 let inner = &trimmed[brace_start + 1..brace_start + brace_end];
828 let mut classes = Vec::new();
829 for token in inner.split_whitespace() {
830 if let Some(class) = token.strip_prefix('.') {
831 if !class.is_empty() {
832 classes.push(class);
833 }
834 }
835 }
836 return (positional, classes.join(" "), None);
837 }
838 }
839 (trimmed.to_string(), String::new(), None)
840}
841
842fn split_positional_and_classes(args: &str) -> (String, String) {
856 let trimmed = args.trim();
857 if let Some(brace_start) = trimmed.find('{') {
858 #[allow(clippy::string_slice)]
862 let after_open = &trimmed[brace_start..];
863 if let Some(brace_end) = after_open.find('}') {
864 #[allow(clippy::string_slice)]
868 let positional = trimmed[..brace_start].trim().to_string();
869 #[allow(clippy::string_slice)]
870 let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
871 if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
872 return (positional, parsed.class_string());
873 }
874 #[allow(clippy::string_slice)]
877 let inner = &trimmed[brace_start + 1..brace_start + brace_end];
878 let mut classes = Vec::new();
879 for token in inner.split_whitespace() {
880 if let Some(class) = token.strip_prefix('.') {
881 if !class.is_empty() {
882 classes.push(class);
883 }
884 }
885 }
886 return (positional, classes.join(" "));
887 }
888 }
889 (trimmed.to_string(), String::new())
890}
891
892fn split_pipe(s: &str) -> (&str, &str) {
894 match s.split_once('|') {
895 Some((before, after)) => (before, after.trim()),
896 None => (s, ""),
897 }
898}
899
900fn parse_markdown_image(s: &str) -> Option<(String, String)> {
903 let s = s.trim();
904 let rest = s.strip_prefix("?;
906 let close_paren = after.rfind(')')?;
907 #[allow(clippy::string_slice)]
909 let path = &after[..close_paren];
910 if path.contains('(') {
911 return None;
912 }
913 Some((alt.to_string(), path.to_string()))
914}
915
916fn parse_buttons_body(args: &str, body: &str) -> ButtonsShortcode {
917 let (_positional, classes) = split_positional_and_classes(args);
918 let mut items: Vec<ButtonItem> = Vec::new();
919 for cell in split_cells(body) {
924 for line in cell.lines() {
925 let trimmed = line.trim();
926 if trimmed.is_empty() {
927 continue;
928 }
929 if let Some((text, url)) = extract_markdown_link(trimmed) {
930 items.push(ButtonItem {
931 text,
932 url: Url::unresolved(url),
933 });
934 }
935 }
937 }
938 ButtonsShortcode { classes, items }
939}
940
941fn extract_markdown_link(s: &str) -> Option<(String, String)> {
944 let s = s.trim();
945 let inside = s.strip_prefix('[')?;
946 let (text, after) = inside.split_once(']')?;
947 let url = after.strip_prefix('(').and_then(|r| r.strip_suffix(')'))?;
948 if url.is_empty() {
949 return None;
950 }
951 Some((text.to_string(), url.to_string()))
952}
953
954fn parse_subscribe_args(args: &str) -> SubscribeShortcode {
961 let parsed = match super::attrs::parse_attrs(args) {
964 Ok(b) => b,
965 Err(_) => return SubscribeShortcode::default(),
966 };
967 let placeholder = parsed
968 .get("placeholder")
969 .filter(|s| !s.is_empty())
970 .map(str::to_string);
971 let button = parsed
972 .get("button")
973 .filter(|s| !s.is_empty())
974 .map(str::to_string);
975 SubscribeShortcode {
976 placeholder,
977 button,
978 }
979}
980
981pub fn parse_apply_args(args: &str) -> ApplyShortcode {
987 let parsed = match super::attrs::parse_attrs(args) {
988 Ok(b) => b,
989 Err(_) => return ApplyShortcode::default(),
990 };
991 let placeholder = parsed
992 .get("placeholder")
993 .filter(|s| !s.is_empty())
994 .map(str::to_string);
995 let button = parsed
996 .get("button")
997 .filter(|s| !s.is_empty())
998 .map(str::to_string);
999 ApplyShortcode {
1000 placeholder,
1001 button,
1002 }
1003}
1004
1005pub fn placeholder_for(nonce: &str, index: usize) -> String {
1013 format!("<!--MOSS_SC_{nonce}_{index}-->")
1014}
1015
1016pub fn parse_placeholder(nonce: &str, html: &str) -> Option<usize> {
1022 let trim = html.trim();
1023 let prefix = format!("<!--MOSS_SC_{nonce}_");
1024 let inner = trim.strip_prefix(&prefix)?;
1025 let inner = inner.strip_suffix("-->")?;
1026 inner.parse::<usize>().ok()
1027}
1028
1029fn compute_nonce(input: &str) -> String {
1034 use std::hash::{Hash, Hasher};
1035 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1036 input.hash(&mut hasher);
1037 let h = hasher.finish() as u32;
1043 format!("{h:08x}")
1044}
1045
1046pub fn extract_shortcodes(markdown: &str) -> ExtractionResult {
1054 let nonce = compute_nonce(markdown);
1055 let mut extracted: Vec<ExtractedShortcode> = Vec::new();
1056 let mut warnings: Vec<String> = Vec::new();
1057 let output = extract_with_state(markdown, &nonce, &mut extracted, &mut warnings);
1058 ExtractionResult {
1059 markdown_with_placeholders: output,
1060 extracted,
1061 nonce,
1062 warnings,
1063 }
1064}
1065
1066fn extract_with_state(
1073 markdown: &str,
1074 nonce: &str,
1075 extracted: &mut Vec<ExtractedShortcode>,
1076 warnings: &mut Vec<String>,
1077) -> String {
1078 let mut output = String::with_capacity(markdown.len());
1079 let lines: Vec<&str> = markdown.lines().collect();
1080 let mut i = 0;
1081 let mut in_code_fence = false;
1082 let mut fence_marker = String::new();
1083
1084 while i < lines.len() {
1085 let line = lines[i];
1086 let trimmed = line.trim();
1087
1088 if in_code_fence {
1090 output.push_str(line);
1091 output.push('\n');
1092 let fence_char = fence_marker.chars().next().unwrap_or(' ');
1100 if trimmed.starts_with(&fence_marker)
1101 && trimmed.trim_start_matches(fence_char).trim().is_empty()
1102 {
1103 in_code_fence = false;
1104 fence_marker.clear();
1105 }
1106 i += 1;
1107 continue;
1108 }
1109 if let Some(marker) = detect_code_fence_open(trimmed) {
1110 in_code_fence = true;
1111 fence_marker = marker;
1112 output.push_str(line);
1113 output.push('\n');
1114 i += 1;
1115 continue;
1116 }
1117
1118 if let Some((arity, name, single_line_args)) = parse_shortcode_opener(trimmed) {
1120 let (args_owned, opener_lines_consumed) =
1129 gather_multi_line_attrs(single_line_args, &lines[i + 1..]);
1130 let args: &str = args_owned.as_deref().unwrap_or(single_line_args);
1131 let body_start = i + 1 + opener_lines_consumed;
1132
1133 let mut body_lines: Vec<&str> = Vec::new();
1135 let mut j = body_start;
1136 let mut closed = false;
1137 while j < lines.len() {
1138 if is_close_fence(lines[j].trim(), arity) {
1139 closed = true;
1140 break;
1141 }
1142 body_lines.push(lines[j]);
1143 j += 1;
1144 }
1145
1146 if !closed {
1147 output.push_str(line);
1150 output.push('\n');
1151 i += 1;
1152 continue;
1153 }
1154
1155 let body = body_lines.join("\n");
1156
1157 if name.is_empty() {
1173 let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1181 let body_processed = extract_with_state(&body, nonce, extracted, warnings);
1182 output.push_str(&render_div_open(&parsed.classes, parsed.id.as_deref(), None));
1183 output.push_str("\n\n");
1184 output.push_str(&body_processed);
1185 if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1186 output.push('\n');
1187 }
1188 output.push_str("\n</div>\n");
1189 i = j + 1;
1190 continue;
1191 }
1192
1193 if is_typed_known(name) {
1194 if let (Some(sc), parse_warnings) = parse_shortcode_block(name, args, &body) {
1195 warnings.extend(parse_warnings);
1196 let index = extracted.len();
1197 output.push_str(&placeholder_for(&nonce, index));
1198 output.push('\n');
1199 for _ in 0..(j - i) {
1210 output.push('\n');
1211 }
1212 extracted.push(ExtractedShortcode {
1213 index,
1214 shortcode: sc,
1215 });
1216 i = j + 1;
1217 continue;
1218 }
1219 output.push_str(line);
1223 output.push('\n');
1224 i += 1;
1225 continue;
1226 }
1227
1228 let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1233 warnings.push(format!("unknown shortcode `:::{}`", name));
1234 let mut classes = vec!["moss-unknown-shortcode".to_string()];
1235 classes.extend(parsed.classes.iter().cloned());
1236 let extra_attrs = format!(r#" data-name="{}""#, html_escape_attr(name));
1237 let body_processed = extract_with_state(&body, nonce, extracted, warnings);
1238 output.push_str(&render_div_open(&classes, parsed.id.as_deref(), Some(&extra_attrs)));
1239 output.push_str("\n\n");
1240 output.push_str(&body_processed);
1241 if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1242 output.push('\n');
1243 }
1244 output.push_str("\n</div>\n");
1245 i = j + 1;
1246 continue;
1247 }
1248
1249 output.push_str(line);
1251 output.push('\n');
1252 i += 1;
1253 }
1254
1255 output
1256}
1257
1258fn render_div_open(classes: &[String], id: Option<&str>, extra_attrs: Option<&str>) -> String {
1263 let mut out = String::from("<div");
1264 if !classes.is_empty() {
1265 out.push_str(" class=\"");
1266 for (i, c) in classes.iter().enumerate() {
1267 if i > 0 {
1268 out.push(' ');
1269 }
1270 out.push_str(&html_escape_attr(c));
1271 }
1272 out.push('"');
1273 }
1274 if let Some(id_val) = id {
1275 out.push_str(" id=\"");
1276 out.push_str(&html_escape_attr(id_val));
1277 out.push('"');
1278 }
1279 if let Some(extra) = extra_attrs {
1280 out.push_str(extra);
1281 }
1282 out.push('>');
1283 out
1284}
1285
1286fn html_escape_attr(s: &str) -> String {
1289 let mut out = String::with_capacity(s.len());
1290 for c in s.chars() {
1291 match c {
1292 '&' => out.push_str("&"),
1293 '<' => out.push_str("<"),
1294 '>' => out.push_str(">"),
1295 '"' => out.push_str("""),
1296 '\'' => out.push_str("'"),
1297 _ => out.push(c),
1298 }
1299 }
1300 out
1301}
1302
1303fn detect_code_fence_open(trimmed: &str) -> Option<String> {
1304 if trimmed.starts_with("```") {
1305 Some("```".to_string())
1306 } else if trimmed.starts_with("~~~") {
1307 Some("~~~".to_string())
1308 } else {
1309 None
1310 }
1311}
1312
1313fn parse_shortcode_opener(trimmed: &str) -> Option<(usize, &str, &str)> {
1326 let colons = trimmed.chars().take_while(|&c| c == ':').count();
1327 if colons < 3 {
1328 return None;
1329 }
1330 #[allow(clippy::string_slice)]
1334 let rest = &trimmed[colons..];
1335 let name_end = rest
1337 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
1338 .unwrap_or(rest.len());
1339 if name_end == 0 {
1340 let after_ws = rest.trim_start();
1343 if !after_ws.starts_with('{') {
1344 return None;
1345 }
1346 return Some((colons, "", rest.trim()));
1347 }
1348 #[allow(clippy::string_slice)]
1351 let name = &rest[..name_end];
1352 #[allow(clippy::string_slice)]
1353 let args = rest[name_end..].trim();
1354 Some((colons, name, args))
1355}
1356
1357fn is_close_fence(trimmed: &str, arity: usize) -> bool {
1374 let mut chars = trimmed.chars();
1375 for _ in 0..arity {
1376 match chars.next() {
1377 Some(':') => {}
1378 _ => return false,
1379 }
1380 }
1381 chars.all(char::is_whitespace)
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387 use super::*;
1388
1389 #[test]
1390 fn no_shortcodes_round_trips_input() {
1391 let md = "# Heading\n\npara with [link](u).\n";
1392 let result = extract_shortcodes(md);
1393 assert_eq!(result.markdown_with_placeholders, md);
1394 assert!(result.extracted.is_empty());
1395 }
1396
1397 #[test]
1398 fn extracts_subscribe_block_with_placeholder_and_button_attrs() {
1399 let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
1400:::
1401"#;
1402 let result = extract_shortcodes(md);
1403 assert_eq!(result.extracted.len(), 1);
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("Sign me up"));
1408 }
1409 other => panic!("expected Subscribe, got {other:?}"),
1410 }
1411 assert!(result
1412 .markdown_with_placeholders
1413 .contains(&placeholder_for(&result.nonce, 0)));
1414 assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
1415 }
1416
1417 #[test]
1418 fn extracts_subscribe_block_with_only_placeholder_attr() {
1419 let md = r#":::subscribe {placeholder="hi@example.com"}
1420:::
1421"#;
1422 let result = extract_shortcodes(md);
1423 match &result.extracted[0].shortcode {
1424 Shortcode::Subscribe(args) => {
1425 assert_eq!(args.placeholder.as_deref(), Some("hi@example.com"));
1426 assert!(args.button.is_none());
1427 }
1428 other => panic!("expected Subscribe, got {other:?}"),
1429 }
1430 }
1431
1432 #[test]
1433 fn extracts_subscribe_block_with_no_args() {
1434 let md = ":::subscribe\n:::\n";
1435 let result = extract_shortcodes(md);
1436 match &result.extracted[0].shortcode {
1437 Shortcode::Subscribe(args) => {
1438 assert!(args.placeholder.is_none());
1439 assert!(args.button.is_none());
1440 }
1441 other => panic!("expected Subscribe, got {other:?}"),
1442 }
1443 }
1444
1445 #[test]
1446 fn extracts_subscribe_block_with_multi_line_attrs() {
1447 let md = r#":::subscribe {
1448 placeholder="you@domain.com"
1449 button="Request access"
1450}
1451:::
1452"#;
1453 let result = extract_shortcodes(md);
1454 match &result.extracted[0].shortcode {
1455 Shortcode::Subscribe(args) => {
1456 assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
1457 assert_eq!(args.button.as_deref(), Some("Request access"));
1458 }
1459 other => panic!("expected Subscribe, got {other:?}"),
1460 }
1461 }
1462
1463 #[test]
1464 fn subscribe_legacy_body_keys_no_longer_parsed() {
1465 let md = ":::subscribe\ndescription: Get updates\n:::\n";
1469 let result = extract_shortcodes(md);
1470 match &result.extracted[0].shortcode {
1471 Shortcode::Subscribe(args) => {
1472 assert!(args.placeholder.is_none(), "old description body must not populate placeholder");
1473 assert!(args.button.is_none());
1474 }
1475 other => panic!("expected Subscribe, got {other:?}"),
1476 }
1477 }
1478
1479 #[test]
1480 fn subscribe_inside_code_fence_is_not_extracted() {
1481 let md = "```\n:::subscribe\ndescription: doc\n:::\n```\n";
1485 let result = extract_shortcodes(md);
1486 assert!(result.extracted.is_empty());
1487 assert!(result.markdown_with_placeholders.contains(":::subscribe"));
1488 }
1489
1490 #[test]
1491 fn subscribe_inside_tilde_fence_is_not_extracted() {
1492 let md = "~~~\n:::subscribe\n:::\n~~~\n";
1493 let result = extract_shortcodes(md);
1494 assert!(result.extracted.is_empty());
1495 }
1496
1497 #[test]
1498 fn unclosed_subscribe_block_emits_verbatim() {
1499 let md = ":::subscribe\nbutton: Go\n";
1501 let result = extract_shortcodes(md);
1502 assert!(result.extracted.is_empty());
1503 assert!(result.markdown_with_placeholders.contains(":::subscribe"));
1504 }
1505
1506 #[test]
1507 fn extracts_hero_block_with_body_image_typed() {
1508 let md = ":::hero\n![[bg.jpg]]\n:::\n";
1512 let result = extract_shortcodes(md);
1513 assert_eq!(result.extracted.len(), 1);
1514 match &result.extracted[0].shortcode {
1515 Shortcode::Hero(args) => match &args.image {
1516 Some(Url::Unresolved(s)) => assert_eq!(s, "bg.jpg"),
1517 _ => panic!("expected Unresolved bg.jpg"),
1518 },
1519 _ => panic!("expected Hero"),
1520 }
1521 assert!(!result.markdown_with_placeholders.contains(":::hero"));
1523 }
1524
1525 #[test]
1526 fn extracts_multiple_subscribes_with_increasing_indices() {
1527 let md = ":::subscribe\ndescription: a\n:::\n\nsome text\n\n:::subscribe\nbutton: b\n:::\n";
1528 let result = extract_shortcodes(md);
1529 assert_eq!(result.extracted.len(), 2);
1530 assert_eq!(result.extracted[0].index, 0);
1531 assert_eq!(result.extracted[1].index, 1);
1532 assert!(result
1533 .markdown_with_placeholders
1534 .contains(&placeholder_for(&result.nonce, 0)));
1535 assert!(result
1536 .markdown_with_placeholders
1537 .contains(&placeholder_for(&result.nonce, 1)));
1538 }
1539
1540 #[test]
1541 fn parse_placeholder_round_trips_index() {
1542 let nonce = "deadbeef";
1543 for index in [0, 1, 5, 99] {
1544 let s = placeholder_for(nonce, index);
1545 assert_eq!(parse_placeholder(nonce, &s), Some(index));
1546 }
1547 }
1548
1549 #[test]
1550 fn parse_placeholder_rejects_non_placeholder_html() {
1551 let nonce = "deadbeef";
1552 assert!(parse_placeholder(nonce, "<div>hi</div>").is_none());
1553 assert!(parse_placeholder(nonce, "<!--just a comment-->").is_none());
1554 }
1555
1556 #[test]
1557 fn parse_placeholder_rejects_wrong_nonce() {
1558 let s = placeholder_for("aaaa1111", 5);
1564 assert_eq!(parse_placeholder("bbbb2222", &s), None);
1565 }
1566
1567 #[test]
1568 fn extract_uses_content_derived_nonce() {
1569 let md = ":::subscribe\n:::\n";
1572 let r1 = extract_shortcodes(md);
1573 let r2 = extract_shortcodes(md);
1574 assert_eq!(r1.nonce, r2.nonce);
1575 let r3 = extract_shortcodes(":::subscribe\ndescription: x\n:::\n");
1578 assert_ne!(r1.nonce, r3.nonce);
1579 }
1580
1581 #[test]
1582 fn nonce_makes_authored_collision_inert() {
1583 let md = ":::subscribe\n:::\n\nLook: <!--MOSS_SC_00000000_0-->\n";
1587 let result = extract_shortcodes(md);
1588 assert_ne!(result.nonce, "00000000");
1591 assert!(result
1592 .markdown_with_placeholders
1593 .contains("MOSS_SC_00000000_0"));
1594 }
1595
1596 #[test]
1597 fn parse_shortcode_opener_recognizes_simple_name() {
1598 assert_eq!(
1599 parse_shortcode_opener(":::subscribe"),
1600 Some((3, "subscribe", ""))
1601 );
1602 }
1603
1604 #[test]
1605 fn parse_shortcode_opener_extracts_args() {
1606 assert_eq!(
1607 parse_shortcode_opener(":::grid 3 1:2:1"),
1608 Some((3, "grid", "3 1:2:1"))
1609 );
1610 }
1611
1612 #[test]
1613 fn parse_shortcode_opener_recognizes_quadruple_colon() {
1614 assert_eq!(
1617 parse_shortcode_opener("::::buttons"),
1618 Some((4, "buttons", ""))
1619 );
1620 }
1621
1622 #[test]
1623 fn parse_shortcode_opener_rejects_two_colons() {
1624 assert!(parse_shortcode_opener("::name").is_none());
1626 }
1627
1628 #[test]
1629 fn extracts_quadruple_colon_buttons() {
1630 let md = "::::buttons\n[Tickets](go/)\n::::\n";
1633 let result = extract_shortcodes(md);
1634 assert_eq!(result.extracted.len(), 1);
1635 match &result.extracted[0].shortcode {
1636 Shortcode::Buttons(args) => {
1637 assert_eq!(args.items.len(), 1);
1638 assert_eq!(args.items[0].text, "Tickets");
1639 }
1640 _ => panic!("expected Buttons"),
1641 }
1642 }
1643
1644 #[test]
1645 fn extracts_grid_with_nested_buttons_via_arity() {
1646 let md = ":::grid 2\n::::buttons\n[Tickets](go/)\n::::\n+++\nfooter cell\n:::\n";
1653 let result = extract_shortcodes(md);
1654 assert_eq!(result.extracted.len(), 1);
1655 match &result.extracted[0].shortcode {
1656 Shortcode::Grid(grid) => {
1657 assert_eq!(grid.columns, 2);
1658 assert_eq!(grid.cells.len(), 2);
1659 let has_typed_buttons = grid.cells[0].iter().any(|b| matches!(
1661 b,
1662 Block::Shortcode(Shortcode::Buttons(args)) if args.items.len() == 1
1663 && args.items[0].text == "Tickets"
1664 ));
1665 assert!(has_typed_buttons, "expected typed Buttons in cell[0]; got {:?}", grid.cells[0]);
1666 let has_footer_para = grid.cells[1].iter().any(|b| matches!(
1668 b,
1669 Block::Paragraph(inlines) if inlines.iter().any(|i| matches!(
1670 i,
1671 super::super::node::Inline::Text(t) if t.contains("footer cell")
1672 ))
1673 ));
1674 assert!(has_footer_para, "expected footer paragraph in cell[1]; got {:?}", grid.cells[1]);
1675 }
1676 other => panic!("expected Grid, got {other:?}"),
1677 }
1678 assert!(!result.markdown_with_placeholders.contains(":::grid 2"));
1681 }
1682
1683 #[test]
1684 fn arity_mismatch_does_not_close_block() {
1685 let md = "::::buttons\n[t](u)\n:::\n[t2](u2)\n::::\n";
1689 let result = extract_shortcodes(md);
1690 assert_eq!(result.extracted.len(), 1);
1692 match &result.extracted[0].shortcode {
1693 Shortcode::Buttons(args) => {
1694 assert_eq!(args.items.len(), 2);
1696 }
1697 _ => panic!("expected Buttons"),
1698 }
1699 }
1700
1701 #[test]
1704 fn extracts_buttons_block_with_one_link() {
1705 let md = ":::buttons\n[Documentation](docs/)\n:::\n";
1706 let result = extract_shortcodes(md);
1707 assert_eq!(result.extracted.len(), 1);
1708 match &result.extracted[0].shortcode {
1709 Shortcode::Buttons(args) => {
1710 assert!(args.classes.is_empty());
1711 assert_eq!(args.items.len(), 1);
1712 assert_eq!(args.items[0].text, "Documentation");
1713 match &args.items[0].url {
1714 Url::Unresolved(s) => assert_eq!(s, "docs/"),
1715 _ => panic!("expected Unresolved"),
1716 }
1717 }
1718 _ => panic!("expected Buttons"),
1719 }
1720 }
1721
1722 #[test]
1723 fn extracts_buttons_block_with_multiple_links() {
1724 let md = ":::buttons\n[Docs](docs/)\n[GitHub](https://github.com)\n:::\n";
1725 let result = extract_shortcodes(md);
1726 match &result.extracted[0].shortcode {
1727 Shortcode::Buttons(args) => {
1728 assert_eq!(args.items.len(), 2);
1729 assert_eq!(args.items[0].text, "Docs");
1730 assert_eq!(args.items[1].text, "GitHub");
1731 }
1732 _ => panic!("expected Buttons"),
1733 }
1734 }
1735
1736 #[test]
1737 fn extracts_buttons_block_with_class_attrs() {
1738 let md = ":::buttons {.primary .large}\n[Go](go/)\n:::\n";
1739 let result = extract_shortcodes(md);
1740 match &result.extracted[0].shortcode {
1741 Shortcode::Buttons(args) => {
1742 assert_eq!(args.classes, "primary large");
1743 assert_eq!(args.items.len(), 1);
1744 }
1745 _ => panic!("expected Buttons"),
1746 }
1747 }
1748
1749 #[test]
1750 fn extracts_buttons_with_moss_resolved_url_intact() {
1751 let md = ":::buttons\n[Docs](moss-resolved:docs/index.md)\n:::\n";
1755 let result = extract_shortcodes(md);
1756 match &result.extracted[0].shortcode {
1757 Shortcode::Buttons(args) => match &args.items[0].url {
1758 Url::Unresolved(s) => assert_eq!(s, "moss-resolved:docs/index.md"),
1759 _ => panic!("expected Unresolved"),
1760 },
1761 _ => panic!("expected Buttons"),
1762 }
1763 }
1764
1765 #[test]
1766 fn buttons_skips_non_link_lines() {
1767 let md = ":::buttons\nNot a link, just text.\n[Real](real/)\n\n:::\n";
1770 let result = extract_shortcodes(md);
1771 match &result.extracted[0].shortcode {
1772 Shortcode::Buttons(args) => {
1773 assert_eq!(args.items.len(), 1);
1774 assert_eq!(args.items[0].text, "Real");
1775 }
1776 _ => panic!("expected Buttons"),
1777 }
1778 }
1779
1780 #[test]
1781 fn buttons_inside_code_fence_is_not_extracted() {
1782 let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
1783 let result = extract_shortcodes(md);
1784 assert!(result.extracted.is_empty());
1785 }
1786
1787 #[test]
1788 fn extract_markdown_link_rejects_text_with_close_bracket() {
1789 let md = ":::buttons\n[a]b](u)\n:::\n";
1795 let result = extract_shortcodes(md);
1796 match &result.extracted[0].shortcode {
1797 Shortcode::Buttons(args) => assert!(args.items.is_empty()),
1798 _ => panic!("expected Buttons"),
1799 }
1800 }
1801
1802 #[test]
1803 fn extract_markdown_link_requires_trailing_paren() {
1804 let md = ":::buttons\n[t](u) <!-- trailing -->\n:::\n";
1807 let result = extract_shortcodes(md);
1808 match &result.extracted[0].shortcode {
1809 Shortcode::Buttons(args) => assert!(args.items.is_empty()),
1810 _ => panic!("expected Buttons"),
1811 }
1812 }
1813
1814 #[test]
1815 fn close_fence_with_trailing_whitespace_is_recognized() {
1816 let md = ":::subscribe\nbutton: x\n::: \n";
1819 let result = extract_shortcodes(md);
1820 assert_eq!(result.extracted.len(), 1);
1821 }
1822
1823 #[test]
1824 fn is_close_fence_handles_multibyte_utf8_lines() {
1825 assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 3));
1832 assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 4));
1833 assert!(!is_close_fence("中文内容", 3));
1835 assert!(!is_close_fence("日本語", 3));
1836 assert!(is_close_fence(":::", 3));
1838 assert!(is_close_fence("::::", 4));
1839 }
1840
1841 #[test]
1842 fn extract_shortcodes_handles_buttons_with_cjk_link_text() {
1843 let md = ":::buttons\n[申请测试版](#青苔正在封闭测试)\n[文档](docs/)\n:::\n";
1848 let result = extract_shortcodes(md);
1849 assert_eq!(result.extracted.len(), 1);
1850 match &result.extracted[0].shortcode {
1851 Shortcode::Buttons(args) => {
1852 assert_eq!(args.items.len(), 2);
1853 assert_eq!(args.items[0].text, "申请测试版");
1854 match &args.items[0].url {
1855 Url::Unresolved(s) => assert_eq!(s, "#青苔正在封闭测试"),
1856 _ => panic!("expected Unresolved"),
1857 }
1858 assert_eq!(args.items[1].text, "文档");
1859 }
1860 _ => panic!("expected Buttons"),
1861 }
1862 }
1863
1864 #[test]
1865 fn extract_shortcodes_does_not_panic_on_arbitrary_cjk_content() {
1866 let md = "# 标题\n\n中文段落,混合 English 单词。\n\n:::buttons\n[申请测试版](#锚点)\n:::\n\n## 二级标题\n\n更多内容。\n";
1872 let result = extract_shortcodes(md);
1873 assert_eq!(result.extracted.len(), 1);
1874 }
1875
1876 #[test]
1877 fn close_fence_with_trailing_text_is_not_recognized() {
1878 let md = ":::buttons\n[a](u)\n::: more text\n[b](v)\n:::\n";
1884 let result = extract_shortcodes(md);
1885 assert_eq!(result.extracted.len(), 1);
1886 match &result.extracted[0].shortcode {
1889 Shortcode::Buttons(args) => {
1890 assert_eq!(args.items.len(), 2);
1891 assert_eq!(args.items[0].text, "a");
1892 assert_eq!(args.items[1].text, "b");
1893 }
1894 _ => panic!("expected Buttons"),
1895 }
1896 }
1897
1898 #[test]
1901 fn extracts_gallery_with_bare_paths() {
1902 let md = ":::gallery\nphoto1.jpg\nphoto2.png\n:::\n";
1903 let result = extract_shortcodes(md);
1904 assert_eq!(result.extracted.len(), 1);
1905 match &result.extracted[0].shortcode {
1906 Shortcode::Gallery(args) => {
1907 assert!(args.columns.is_none());
1908 assert_eq!(args.items.len(), 2);
1909 assert_eq!(args.items[0].alt, "");
1910 match &args.items[0].src {
1911 Url::Unresolved(s) => assert_eq!(s, "photo1.jpg"),
1912 _ => panic!("expected Unresolved"),
1913 }
1914 }
1915 _ => panic!("expected Gallery"),
1916 }
1917 }
1918
1919 #[test]
1920 fn extracts_gallery_with_columns_arg() {
1921 let md = ":::gallery 4\na.jpg\n:::\n";
1922 let result = extract_shortcodes(md);
1923 match &result.extracted[0].shortcode {
1924 Shortcode::Gallery(args) => assert_eq!(args.columns, Some(4)),
1925 _ => panic!("expected Gallery"),
1926 }
1927 }
1928
1929 #[test]
1930 fn extracts_gallery_with_classes() {
1931 let md = ":::gallery 3 {.showcase}\na.jpg\n:::\n";
1932 let result = extract_shortcodes(md);
1933 match &result.extracted[0].shortcode {
1934 Shortcode::Gallery(args) => {
1935 assert_eq!(args.columns, Some(3));
1936 assert_eq!(args.classes, "showcase");
1937 }
1938 _ => panic!("expected Gallery"),
1939 }
1940 }
1941
1942 #[test]
1943 fn extracts_gallery_with_markdown_image_syntax() {
1944 let md = ":::gallery\n\n:::\n";
1945 let result = extract_shortcodes(md);
1946 match &result.extracted[0].shortcode {
1947 Shortcode::Gallery(args) => {
1948 assert_eq!(args.items[0].alt, "A photo");
1949 match &args.items[0].src {
1950 Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
1951 _ => panic!("expected Unresolved"),
1952 }
1953 }
1954 _ => panic!("expected Gallery"),
1955 }
1956 }
1957
1958 #[test]
1959 fn extracts_gallery_with_pipe_attrs() {
1960 let md = ":::gallery\nphoto.jpg|cover top\n:::\n";
1961 let result = extract_shortcodes(md);
1962 match &result.extracted[0].shortcode {
1963 Shortcode::Gallery(args) => {
1964 assert_eq!(args.items[0].attrs, "cover top");
1965 match &args.items[0].src {
1966 Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
1967 _ => panic!("expected Unresolved"),
1968 }
1969 }
1970 _ => panic!("expected Gallery"),
1971 }
1972 }
1973
1974 #[test]
1975 fn gallery_skips_blank_lines() {
1976 let md = ":::gallery\n\na.jpg\n\nb.jpg\n\n:::\n";
1977 let result = extract_shortcodes(md);
1978 match &result.extracted[0].shortcode {
1979 Shortcode::Gallery(args) => assert_eq!(args.items.len(), 2),
1980 _ => panic!("expected Gallery"),
1981 }
1982 }
1983
1984 #[test]
1990 fn extracts_buttons_with_multi_line_attrs() {
1991 let md = ":::buttons {\n .primary\n}\n[Go](go/)\n:::\n";
1994 let result = extract_shortcodes(md);
1995 assert_eq!(result.extracted.len(), 1);
1996 match &result.extracted[0].shortcode {
1997 Shortcode::Buttons(args) => {
1998 assert_eq!(args.classes, "primary");
1999 assert_eq!(args.items.len(), 1);
2000 assert_eq!(args.items[0].text, "Go");
2001 }
2002 _ => panic!("expected Buttons"),
2003 }
2004 }
2005
2006 #[test]
2007 fn extracts_gallery_with_multi_line_attrs() {
2008 let md = ":::gallery {\n .showcase\n}\nphoto.jpg\n:::\n";
2009 let result = extract_shortcodes(md);
2010 match &result.extracted[0].shortcode {
2011 Shortcode::Gallery(args) => {
2012 assert_eq!(args.classes, "showcase");
2013 assert_eq!(args.items.len(), 1);
2014 }
2015 _ => panic!("expected Gallery"),
2016 }
2017 }
2018
2019 #[test]
2020 fn multi_line_attrs_with_quoted_brace_inside() {
2021 let md = ":::buttons {\n .a\n .b\n}\n[Go](go/)\n:::\n";
2024 let result = extract_shortcodes(md);
2025 assert_eq!(result.extracted.len(), 1);
2026 match &result.extracted[0].shortcode {
2027 Shortcode::Buttons(args) => {
2028 assert_eq!(args.classes, "a b");
2030 }
2031 _ => panic!("expected Buttons"),
2032 }
2033 }
2034
2035 #[test]
2038 fn css_region_unnamed_emits_div_wrapper() {
2039 let md = ":::{.tagline}\nA new way to publish.\n:::\n";
2040 let result = extract_shortcodes(md);
2041 assert!(result.extracted.is_empty());
2042 assert!(result
2043 .markdown_with_placeholders
2044 .contains("<div class=\"tagline\">"));
2045 assert!(result
2046 .markdown_with_placeholders
2047 .contains("A new way to publish."));
2048 assert!(result.markdown_with_placeholders.contains("</div>"));
2049 }
2050
2051 #[test]
2052 fn css_region_with_id_only() {
2053 let md = ":::{#intro}\nIntro prose.\n:::\n";
2054 let result = extract_shortcodes(md);
2055 assert!(result
2056 .markdown_with_placeholders
2057 .contains("<div id=\"intro\">"));
2058 }
2059
2060 #[test]
2061 fn css_region_with_classes_and_id() {
2062 let md = ":::{.callout #important}\nWatch out.\n:::\n";
2063 let result = extract_shortcodes(md);
2064 let out = &result.markdown_with_placeholders;
2065 assert!(out.contains("<div"));
2066 assert!(out.contains("class=\"callout\""));
2067 assert!(out.contains("id=\"important\""));
2068 }
2069
2070 #[test]
2071 fn css_region_emits_blank_lines_around_body_for_markdown_processing() {
2072 let md = ":::{.foo}\n# Heading\n:::\n";
2075 let out = extract_shortcodes(md).markdown_with_placeholders;
2076 assert!(out.contains(">\n\n# Heading"));
2078 assert!(out.contains("# Heading\n\n</div>"));
2080 }
2081
2082 #[test]
2083 fn css_region_no_warning_emitted() {
2084 let md = ":::{.foo}\nbody\n:::\n";
2085 assert!(extract_shortcodes(md).warnings.is_empty());
2086 }
2087
2088 #[test]
2091 fn unknown_name_renders_fallback_wrapper() {
2092 let md = ":::nope {.extra}\nbody text\n:::\n";
2093 let result = extract_shortcodes(md);
2094 let out = &result.markdown_with_placeholders;
2095 assert!(out.contains("class=\"moss-unknown-shortcode extra\""));
2096 assert!(out.contains(r#"data-name="nope""#));
2097 assert!(out.contains("body text"));
2098 }
2099
2100 #[test]
2101 fn unknown_name_emits_build_warning() {
2102 let md = ":::nope\n:::\n";
2103 let warnings = extract_shortcodes(md).warnings;
2104 assert_eq!(warnings.len(), 1);
2105 assert!(warnings[0].contains("nope"));
2106 }
2107
2108 #[test]
2109 fn unknown_name_html_escapes_data_name() {
2110 let md = ":::weird-name\nbody\n:::\n";
2115 let out = extract_shortcodes(md).markdown_with_placeholders;
2116 assert!(out.contains(r#"data-name="weird-name""#));
2117 }
2118
2119 #[test]
2123 fn extracts_grid_with_positional_columns() {
2124 let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
2131 let result = extract_shortcodes(md);
2132 assert_eq!(result.extracted.len(), 1);
2133 match &result.extracted[0].shortcode {
2134 Shortcode::Grid(grid) => {
2135 assert_eq!(grid.columns, 2);
2136 assert!(grid.ratio.is_none());
2137 assert_eq!(grid.cells.len(), 2);
2138 assert_paragraph_text(&grid.cells[0], "cell A");
2139 assert_paragraph_text(&grid.cells[1], "cell B");
2140 }
2141 other => panic!("expected Grid, got {other:?}"),
2142 }
2143 }
2144
2145 fn assert_paragraph_text(cell_blocks: &[Block], expected: &str) {
2151 if cell_blocks.is_empty() && expected.is_empty() {
2152 return;
2153 }
2154 let para = match cell_blocks {
2155 [Block::Paragraph(inlines)] => inlines,
2156 other => panic!(
2157 "expected single Paragraph cell with text {expected:?}, got: {other:?}"
2158 ),
2159 };
2160 let mut text = String::new();
2161 for inline in para {
2162 match inline {
2163 super::super::node::Inline::Text(t) => text.push_str(t),
2164 super::super::node::Inline::Code(c) => text.push_str(c),
2165 _ => {}
2166 }
2167 }
2168 assert_eq!(text, expected, "cell text mismatch");
2169 }
2170
2171 #[test]
2172 fn extracts_grid_with_positional_ratio() {
2173 let md = ":::grid 2 1:2\nleft\n---\nright\n:::\n";
2174 let result = extract_shortcodes(md);
2175 match &result.extracted[0].shortcode {
2176 Shortcode::Grid(grid) => {
2177 assert_eq!(grid.columns, 2);
2178 assert_eq!(grid.ratio.as_deref(), Some("1:2"));
2179 }
2180 _ => panic!("expected Grid"),
2181 }
2182 }
2183
2184 #[test]
2185 fn extracts_grid_with_cols_attr_integer() {
2186 let md = ":::grid {cols=3}\nA\n+++\nB\n+++\nC\n:::\n";
2187 let result = extract_shortcodes(md);
2188 match &result.extracted[0].shortcode {
2189 Shortcode::Grid(grid) => {
2190 assert_eq!(grid.columns, 3);
2191 assert_eq!(grid.cells.len(), 3);
2192 assert_paragraph_text(&grid.cells[0], "A");
2193 assert_paragraph_text(&grid.cells[1], "B");
2194 assert_paragraph_text(&grid.cells[2], "C");
2195 }
2196 _ => panic!("expected Grid"),
2197 }
2198 }
2199
2200 #[test]
2201 fn extracts_grid_with_cols_attr_ratio_implies_count() {
2202 let md = ":::grid {cols=1:1:2}\nA\n+++\nB\n+++\nC\n:::\n";
2203 let result = extract_shortcodes(md);
2204 match &result.extracted[0].shortcode {
2205 Shortcode::Grid(grid) => {
2206 assert_eq!(grid.columns, 3, "ratio length implies column count");
2207 assert_eq!(grid.ratio.as_deref(), Some("1:1:2"));
2208 }
2209 _ => panic!("expected Grid"),
2210 }
2211 }
2212
2213 #[test]
2214 fn extracts_grid_accepts_plus_plus_plus_divider() {
2215 let md = ":::grid 2\nA\n+++\nB\n:::\n";
2216 let result = extract_shortcodes(md);
2217 match &result.extracted[0].shortcode {
2218 Shortcode::Grid(grid) => {
2219 assert_eq!(grid.cells.len(), 2);
2220 assert_paragraph_text(&grid.cells[0], "A");
2221 assert_paragraph_text(&grid.cells[1], "B");
2222 }
2223 _ => panic!("expected Grid"),
2224 }
2225 }
2226
2227 #[test]
2228 fn extracts_grid_with_classes() {
2229 let md = ":::grid 3 {.work-cards .featured}\nA\n---\nB\n---\nC\n:::\n";
2230 let result = extract_shortcodes(md);
2231 match &result.extracted[0].shortcode {
2232 Shortcode::Grid(grid) => {
2233 assert_eq!(grid.columns, 3);
2234 assert_eq!(grid.classes, "work-cards featured");
2235 }
2236 _ => panic!("expected Grid"),
2237 }
2238 }
2239
2240 #[test]
2241 fn extracts_grid_single_cell_no_separator() {
2242 let md = ":::grid 1\nonly cell\n:::\n";
2243 let result = extract_shortcodes(md);
2244 match &result.extracted[0].shortcode {
2245 Shortcode::Grid(grid) => {
2246 assert_eq!(grid.columns, 1);
2247 assert_eq!(grid.cells.len(), 1);
2248 assert_paragraph_text(&grid.cells[0], "only cell");
2249 }
2250 _ => panic!("expected Grid"),
2251 }
2252 }
2253
2254 #[test]
2255 fn extracts_grid_with_empty_middle_cell() {
2256 let md = ":::grid 3\nA\n+++\n+++\nC\n:::\n";
2261 let result = extract_shortcodes(md);
2262 match &result.extracted[0].shortcode {
2263 Shortcode::Grid(grid) => {
2264 assert_eq!(grid.cells.len(), 3);
2265 assert_paragraph_text(&grid.cells[0], "A");
2266 assert!(grid.cells[1].is_empty(), "empty cell should have no blocks");
2267 assert_paragraph_text(&grid.cells[2], "C");
2268 }
2269 _ => panic!("expected Grid"),
2270 }
2271 }
2272
2273 #[test]
2274 fn nested_grid_via_arity_is_unsupported_authoring() {
2275 let md = "::::grid 1\n:::grid 2\nA\n+++\nB\n:::\n::::\n";
2287 let result = extract_shortcodes(md);
2288 assert_eq!(result.extracted.len(), 1);
2292 match &result.extracted[0].shortcode {
2293 Shortcode::Grid(outer) => {
2294 assert_eq!(outer.columns, 1);
2295 assert!(outer.cells.len() >= 2,
2298 "outer's body got split by inner's +++, demonstrating the \
2299 unsupported-nesting failure mode");
2300 }
2301 _ => panic!("expected Grid"),
2302 }
2303 }
2304
2305 #[test]
2306 fn extracts_grid_with_compound_link_cell_typed_as_link_card() {
2307 let md = ":::grid 2 {.work-cards}\n[![[poster.jpg]]\n#### Title\nbody](/url)\n+++\n[Card 2](/url2)\n:::\n";
2316 let result = extract_shortcodes(md);
2317 match &result.extracted[0].shortcode {
2318 Shortcode::Grid(grid) => {
2319 assert_eq!(grid.classes, "work-cards");
2320 assert_eq!(grid.cells.len(), 2);
2321 match &grid.cells[0][..] {
2322 [Block::LinkCard { url, children }] => {
2323 match url {
2324 Url::Unresolved(u) => assert_eq!(u, "/url"),
2325 _ => panic!("expected Unresolved /url"),
2326 }
2327 assert!(!children.is_empty(), "compound-link inner blocks empty");
2331 }
2332 other => panic!("expected single LinkCard cell, got {other:?}"),
2333 }
2334 match &grid.cells[1][..] {
2335 [Block::LinkCard { url, .. }] => match url {
2336 Url::Unresolved(u) => assert_eq!(u, "/url2"),
2337 _ => panic!("expected Unresolved /url2"),
2338 },
2339 other => panic!("expected LinkCard for cell[1], got {other:?}"),
2340 }
2341 }
2342 _ => panic!("expected Grid"),
2343 }
2344 }
2345
2346 #[test]
2351 fn toc_now_renders_as_unknown_shortcode() {
2352 let md = ":::toc\n:::\n";
2357 let result = extract_shortcodes(md);
2358 assert!(result.extracted.is_empty(), "toc is no longer typed");
2359 assert_eq!(result.warnings.len(), 1, "unknown-name fallback warning");
2360 assert!(result.warnings[0].contains("toc"));
2361 assert!(result
2362 .markdown_with_placeholders
2363 .contains(r#"data-name="toc""#));
2364 }
2365
2366 #[test]
2369 fn extracts_hero_block_with_no_image() {
2370 let md = ":::hero\n# A House of Daowu\n:::\n";
2371 let result = extract_shortcodes(md);
2372 assert_eq!(result.extracted.len(), 1, "hero should be extracted");
2373 match &result.extracted[0].shortcode {
2374 Shortcode::Hero(args) => {
2375 assert!(args.image.is_none());
2376 assert_eq!(args.overlay_text, "# A House of Daowu");
2377 }
2378 other => panic!("expected Hero, got {other:?}"),
2379 }
2380 assert!(!result.markdown_with_placeholders.contains(":::hero"));
2382 }
2383
2384 #[test]
2385 fn extracts_hero_block_with_wikilink_body_image() {
2386 let md = ":::hero\n![[panorama.jpg]]\n# Welcome\n:::\n";
2387 let result = extract_shortcodes(md);
2388 assert_eq!(result.extracted.len(), 1);
2389 match &result.extracted[0].shortcode {
2390 Shortcode::Hero(args) => {
2391 match &args.image {
2392 Some(Url::Unresolved(s)) => assert_eq!(s, "panorama.jpg"),
2393 other => panic!("expected Unresolved url, got {other:?}"),
2394 }
2395 assert_eq!(args.overlay_text, "# Welcome");
2396 }
2397 other => panic!("expected Hero, got {other:?}"),
2398 }
2399 }
2400
2401 #[test]
2402 fn extracts_hero_block_with_image_attr() {
2403 let md = ":::hero {image=cover.jpg}\n# Title\n:::\n";
2404 let result = extract_shortcodes(md);
2405 match &result.extracted[0].shortcode {
2406 Shortcode::Hero(args) => {
2407 match &args.image {
2408 Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
2409 other => panic!("expected Unresolved, got {other:?}"),
2410 }
2411 assert_eq!(args.overlay_text, "# Title");
2412 }
2413 other => panic!("expected Hero, got {other:?}"),
2414 }
2415 }
2416
2417 #[test]
2418 fn extracts_hero_block_with_image_attr_and_pipe_attrs() {
2419 let md = r#":::hero {image="cover.jpg|contain top"}
2422:::
2423"#;
2424 let result = extract_shortcodes(md);
2425 match &result.extracted[0].shortcode {
2426 Shortcode::Hero(args) => {
2427 match &args.image {
2428 Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
2429 _ => panic!("expected Unresolved"),
2430 }
2431 assert_eq!(args.attrs, "contain top");
2432 }
2433 _ => panic!("expected Hero"),
2434 }
2435 }
2436
2437 #[test]
2438 fn extracts_hero_block_with_classes() {
2439 let md = ":::hero {.full .center}\n# Title\n:::\n";
2440 let result = extract_shortcodes(md);
2441 match &result.extracted[0].shortcode {
2442 Shortcode::Hero(args) => {
2443 assert_eq!(args.classes, "full center");
2444 }
2445 _ => panic!("expected Hero"),
2446 }
2447 }
2448
2449 #[test]
2450 fn extracts_hero_block_with_directive_line_path() {
2451 let md = ":::hero ./assets/header.png\n:::\n";
2457 let result = extract_shortcodes(md);
2458 assert_eq!(result.extracted.len(), 1);
2459 match &result.extracted[0].shortcode {
2460 Shortcode::Hero(args) => match &args.image {
2461 Some(Url::Unresolved(s)) => assert_eq!(s, "./assets/header.png"),
2462 other => panic!("expected Unresolved ./assets/header.png, got {other:?}"),
2463 },
2464 _ => panic!("expected Hero"),
2465 }
2466 }
2467
2468 #[test]
2469 fn extracts_hero_block_with_directive_line_path_and_pipe_attrs() {
2470 let md = ":::hero ./bg.jpg|contain top\n:::\n";
2471 let result = extract_shortcodes(md);
2472 match &result.extracted[0].shortcode {
2473 Shortcode::Hero(args) => {
2474 match &args.image {
2475 Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
2476 _ => panic!("expected Unresolved"),
2477 }
2478 assert_eq!(args.attrs, "contain top");
2479 }
2480 _ => panic!("expected Hero"),
2481 }
2482 }
2483
2484 #[test]
2485 fn extracts_hero_block_with_directive_line_path_and_classes() {
2486 let md = ":::hero ./bg.jpg {.landing}\n# Welcome\n:::\n";
2489 let result = extract_shortcodes(md);
2490 match &result.extracted[0].shortcode {
2491 Shortcode::Hero(args) => {
2492 match &args.image {
2493 Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
2494 _ => panic!("expected Unresolved"),
2495 }
2496 assert_eq!(args.classes, "landing");
2497 assert_eq!(args.overlay_text, "# Welcome");
2498 }
2499 _ => panic!("expected Hero"),
2500 }
2501 }
2502
2503 #[test]
2506 fn nested_css_region_outer_closes_at_first_inner_close() {
2507 let md = ":::{.outer}\n:::{.inner}\nbody\n:::\n:::\n";
2516 let result = extract_shortcodes(md);
2517 let out = &result.markdown_with_placeholders;
2518 assert!(out.contains("<div class=\"outer\""));
2520 assert!(out.contains(":::{.inner}"));
2523 }
2524
2525 #[test]
2526 fn nested_css_region_higher_arity_outer_recurses_into_inner() {
2527 let md = "::::{.outer}\n:::{.inner}\nbody\n:::\n::::\n";
2532 let result = extract_shortcodes(md);
2533 let out = &result.markdown_with_placeholders;
2534 assert!(out.contains("<div class=\"outer\""));
2535 assert!(out.contains("<div class=\"inner\""));
2536 assert!(!out.contains(":::{.inner}"));
2538 }
2539
2540 #[test]
2541 fn css_region_containing_typed_subscribe_is_not_recursively_extracted() {
2542 let md = ":::{.wrapper}\n:::subscribe\n:::\n:::\n";
2545 let result = extract_shortcodes(md);
2546 assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
2550 assert!(result.extracted.is_empty());
2552 }
2553
2554 #[test]
2555 fn higher_arity_wrapper_recursively_extracts_typed_subscribe() {
2556 let md = "::::{.wrapper}\n:::subscribe\n:::\n::::\n";
2562 let result = extract_shortcodes(md);
2563 assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
2564 assert_eq!(result.extracted.len(), 1);
2565 match &result.extracted[0].shortcode {
2566 Shortcode::Subscribe(_) => {}
2567 _ => panic!("expected Subscribe"),
2568 }
2569 assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
2572 }
2573
2574 #[test]
2575 fn lower_arity_outer_wraps_higher_arity_typed_inner() {
2576 let md = ":::{.support-band}\n## Title\n\n::::buttons {.inverted}\n[Support Us](/support)\n::::\n*footnote*\n:::\n";
2582 let result = extract_shortcodes(md);
2583 let out = &result.markdown_with_placeholders;
2584 assert!(out.contains("<div class=\"support-band\""));
2586 assert_eq!(result.extracted.len(), 1);
2588 match &result.extracted[0].shortcode {
2589 Shortcode::Buttons(args) => {
2590 assert_eq!(args.items.len(), 1);
2591 }
2592 _ => panic!("expected Buttons"),
2593 }
2594 assert!(!out.contains("::::buttons"));
2596 assert!(!out.contains("::::"));
2597 }
2598
2599 #[test]
2600 fn lower_arity_outer_wraps_grid_with_buttons_in_cell() {
2601 let md = "::: {.hero-split}\n::::grid 2 {.no-cards}\nleft\n+++\nright\n::::\n:::\n";
2608 let result = extract_shortcodes(md);
2609 let out = &result.markdown_with_placeholders;
2610 assert!(out.contains("<div class=\"hero-split\""));
2612 assert_eq!(result.extracted.len(), 1);
2614 match &result.extracted[0].shortcode {
2615 Shortcode::Grid(_) => {}
2616 _ => panic!("expected Grid"),
2617 }
2618 assert!(!out.contains("::::grid"));
2620 }
2621
2622 #[test]
2623 fn unknown_name_body_recursively_extracts_typed_inner() {
2624 let md = ":::buttosn\n::::buttons\n[a](u)\n::::\n:::\n";
2630 let result = extract_shortcodes(md);
2631 let out = &result.markdown_with_placeholders;
2632 assert!(out.contains("data-name=\"buttosn\""));
2634 assert_eq!(result.extracted.len(), 1);
2636 match &result.extracted[0].shortcode {
2637 Shortcode::Buttons(_) => {}
2638 _ => panic!("expected Buttons"),
2639 }
2640 }
2641
2642 #[test]
2643 fn unknown_name_with_plus_plus_plus_in_body_passes_through() {
2644 let md = ":::buttosn\n[a](u)\n+++\n[b](v)\n:::\n";
2650 let result = extract_shortcodes(md);
2651 let out = &result.markdown_with_placeholders;
2652 assert!(out.contains(r#"data-name="buttosn""#));
2653 assert!(out.contains("[a](u)"));
2654 assert!(out.contains("+++"));
2655 assert!(out.contains("[b](v)"));
2656 }
2657
2658 #[test]
2659 fn parse_shortcode_opener_recognizes_empty_name_with_attrs() {
2660 assert_eq!(
2661 parse_shortcode_opener(":::{.tagline}"),
2662 Some((3, "", "{.tagline}"))
2663 );
2664 }
2665
2666 #[test]
2667 fn parse_shortcode_opener_rejects_just_colons() {
2668 assert!(parse_shortcode_opener(":::").is_none());
2669 assert!(parse_shortcode_opener("::: ").is_none());
2670 }
2671
2672 #[test]
2673 fn unclosed_multi_line_attrs_block_emits_verbatim() {
2674 let md = ":::buttons {\n .primary\n[Go](go/)\n:::\n";
2677 let result = extract_shortcodes(md);
2678 assert!(result.extracted.is_empty() || matches!(result.extracted[0].shortcode, Shortcode::Buttons(_)));
2683 }
2685
2686 #[test]
2689 fn grid_legacy_dash_emits_deprecation_warning() {
2690 let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
2691 let result = extract_shortcodes(md);
2692 assert_eq!(result.warnings.len(), 1);
2693 assert!(result.warnings[0].contains("deprecated"));
2694 assert!(result.warnings[0].contains("+++"));
2695 }
2696
2697 #[test]
2698 fn grid_plus_plus_plus_no_deprecation_warning() {
2699 let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
2700 let result = extract_shortcodes(md);
2701 assert!(result.warnings.is_empty());
2702 }
2703
2704 #[test]
2705 fn hero_priority3_body_image_emits_deprecation_warning() {
2706 let md = ":::hero\nphoto.jpg\n# Title\n:::\n";
2707 let result = extract_shortcodes(md);
2708 assert_eq!(result.warnings.len(), 1);
2709 assert!(result.warnings[0].contains("deprecated"));
2710 assert!(result.warnings[0].contains("image="));
2711 }
2712
2713 #[test]
2714 fn hero_explicit_image_attr_no_deprecation_warning() {
2715 let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
2716 let result = extract_shortcodes(md);
2717 assert!(result.warnings.is_empty());
2718 }
2719
2720 fn first_extracted(md: &str) -> Shortcode {
2728 let result = extract_shortcodes(md);
2729 result
2730 .extracted
2731 .into_iter()
2732 .next()
2733 .expect("at least one shortcode")
2734 .shortcode
2735 }
2736
2737 #[test]
2738 fn hero_with_full_flag_sets_width_screen() {
2739 let md = ":::hero {image=photo.jpg full}\n# Title\n:::\n";
2740 match first_extracted(md) {
2741 Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
2742 other => panic!("expected Hero, got {other:?}"),
2743 }
2744 }
2745
2746 #[test]
2747 fn hero_with_screen_flag_sets_width_screen() {
2748 let md = ":::hero {image=photo.jpg screen}\n# Title\n:::\n";
2749 match first_extracted(md) {
2750 Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
2751 other => panic!("expected Hero, got {other:?}"),
2752 }
2753 }
2754
2755 #[test]
2756 fn hero_with_wide_flag_sets_width_wide() {
2757 let md = ":::hero {image=photo.jpg wide}\n# Title\n:::\n";
2758 match first_extracted(md) {
2759 Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("wide")),
2760 other => panic!("expected Hero, got {other:?}"),
2761 }
2762 }
2763
2764 #[test]
2765 fn hero_without_width_flag_leaves_width_none() {
2766 let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
2767 match first_extracted(md) {
2768 Shortcode::Hero(h) => assert!(h.width.is_none(), "got {:?}", h.width),
2769 other => panic!("expected Hero, got {other:?}"),
2770 }
2771 }
2772
2773 #[test]
2774 fn hero_mobile_overlay_attr_is_parsed() {
2775 let md = ":::hero {image=hero.jpg mobile=overlay}\n# Title\n:::\n";
2776 let result = extract_shortcodes(md);
2777 assert_eq!(result.extracted.len(), 1);
2778 match &result.extracted[0].shortcode {
2779 Shortcode::Hero(args) => {
2780 assert_eq!(args.mobile.as_deref(), Some("overlay"));
2781 }
2782 other => panic!("expected Hero, got {other:?}"),
2783 }
2784 }
2785
2786 #[test]
2787 fn hero_without_mobile_attr_has_none() {
2788 let md = ":::hero {image=hero.jpg}\n# Title\n:::\n";
2789 let result = extract_shortcodes(md);
2790 match &result.extracted[0].shortcode {
2791 Shortcode::Hero(args) => {
2792 assert!(args.mobile.is_none());
2793 }
2794 other => panic!("expected Hero, got {other:?}"),
2795 }
2796 }
2797
2798 #[test]
2799 fn hero_mobile_overlay_with_body_image_fallback() {
2800 let md = ":::hero {mobile=overlay}\n![[bg.jpg]]\n# Title\n:::\n";
2801 let result = extract_shortcodes(md);
2802 match &result.extracted[0].shortcode {
2803 Shortcode::Hero(args) => {
2804 assert_eq!(args.mobile.as_deref(), Some("overlay"));
2805 assert!(args.image.is_some());
2806 }
2807 other => panic!("expected Hero, got {other:?}"),
2808 }
2809 }
2810
2811 #[test]
2812 fn hero_unknown_mobile_value_emits_warning() {
2813 let md = ":::hero {image=hero.jpg mobile=fullscreen}\n# Title\n:::\n";
2814 let result = extract_shortcodes(md);
2815 assert!(
2816 result.warnings.iter().any(|w| w.contains("unrecognized") && w.contains("fullscreen")),
2817 "expected warning for unknown mobile value, got: {:?}",
2818 result.warnings,
2819 );
2820 assert_eq!(result.extracted.len(), 1);
2822 }
2823
2824 #[test]
2825 fn placeholder_preserves_block_line_count_for_source_line_accuracy() {
2826 let md = "# Title\n\n:::grid 3\n[\n\n](/x)\n+++\n[\n\n](/y)\n:::\n\n## After\n";
2831 let input_lines = md.lines().count();
2832 let result = extract_shortcodes(md);
2833 assert_eq!(
2834 result.markdown_with_placeholders.lines().count(),
2835 input_lines,
2836 "placeholder must preserve the block's line count; got:\n{}",
2837 result.markdown_with_placeholders
2838 );
2839 let after_line = result
2841 .markdown_with_placeholders
2842 .lines()
2843 .position(|l| l.contains("## After"))
2844 .map(|p| p + 1);
2845 assert_eq!(after_line, Some(13), "## After should stay on line 13");
2846 }
2847
2848 #[test]
2849 fn gallery_with_page_flag_sets_width_page() {
2850 let md = ":::gallery 3 {page}\nphoto.jpg\n:::\n";
2851 match first_extracted(md) {
2852 Shortcode::Gallery(g) => assert_eq!(g.width.as_deref(), Some("page")),
2853 other => panic!("expected Gallery, got {other:?}"),
2854 }
2855 }
2856
2857 #[test]
2858 fn gallery_without_width_flag_leaves_width_none() {
2859 let md = ":::gallery 3\nphoto.jpg\n:::\n";
2860 match first_extracted(md) {
2861 Shortcode::Gallery(g) => assert!(g.width.is_none()),
2862 other => panic!("expected Gallery, got {other:?}"),
2863 }
2864 }
2865
2866 #[test]
2867 fn grid_with_wide_flag_sets_width_wide() {
2868 let md = ":::grid {cols=2 wide}\ncell A\n+++\ncell B\n:::\n";
2869 match first_extracted(md) {
2870 Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("wide")),
2871 other => panic!("expected Grid, got {other:?}"),
2872 }
2873 }
2874
2875 #[test]
2876 fn grid_with_full_flag_normalizes_to_screen() {
2877 let md = ":::grid {cols=2 full}\ncell A\n+++\ncell B\n:::\n";
2878 match first_extracted(md) {
2879 Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("screen")),
2880 other => panic!("expected Grid, got {other:?}"),
2881 }
2882 }
2883
2884 #[test]
2885 fn grid_without_width_flag_leaves_width_none() {
2886 let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
2887 match first_extracted(md) {
2888 Shortcode::Grid(g) => assert!(g.width.is_none()),
2889 other => panic!("expected Grid, got {other:?}"),
2890 }
2891 }
2892
2893 #[test]
2896 fn parses_recent_with_since_and_count() {
2897 let (sc, warns) = parse_shortcode_block(
2898 "recent",
2899 r#"{since="2026-04-01" count="5"}"#,
2900 "",
2901 );
2902 assert!(warns.is_empty());
2903 match sc.expect("expected Some(Shortcode)") {
2904 Shortcode::Recent(args) => {
2905 assert_eq!(args.since.as_deref(), Some("2026-04-01"));
2906 assert_eq!(args.count, Some(5));
2907 assert!(args.last.is_none());
2908 assert!(args.fallback_markdown.is_empty());
2909 }
2910 other => panic!("expected Recent, got {other:?}"),
2911 }
2912 }
2913
2914 #[test]
2915 fn parses_recent_with_last_window() {
2916 let (sc, _) = parse_shortcode_block("recent", r#"{last="month"}"#, "");
2917 match sc.expect("expected Some(Shortcode)") {
2918 Shortcode::Recent(args) => {
2919 assert_eq!(args.last.as_deref(), Some("month"));
2920 assert!(args.since.is_none());
2921 assert!(args.count.is_none());
2922 }
2923 other => panic!("expected Recent, got {other:?}"),
2924 }
2925 }
2926
2927 #[test]
2928 fn captures_recent_body_as_fallback_markdown() {
2929 let body = "No posts yet. [Follow along](/).";
2930 let (sc, _) = parse_shortcode_block("recent", "", body);
2931 match sc.expect("expected Some(Shortcode)") {
2932 Shortcode::Recent(args) => {
2933 assert_eq!(args.fallback_markdown, body);
2934 }
2935 other => panic!("expected Recent, got {other:?}"),
2936 }
2937 }
2938
2939 #[test]
2940 fn recent_with_no_args_yields_all_none() {
2941 let (sc, warns) = parse_shortcode_block("recent", "", "");
2942 assert!(warns.is_empty());
2943 match sc.expect("expected Some(Shortcode)") {
2944 Shortcode::Recent(args) => {
2945 assert!(args.since.is_none());
2946 assert!(args.last.is_none());
2947 assert!(args.count.is_none());
2948 assert!(args.fallback_markdown.is_empty());
2949 }
2950 other => panic!("expected Recent, got {other:?}"),
2951 }
2952 }
2953
2954 #[test]
2955 fn parses_recent_with_all_three_attrs() {
2956 let (sc, warns) = parse_shortcode_block(
2957 "recent",
2958 r#"{since="2026-01-01" last="month" count="3"}"#,
2959 "",
2960 );
2961 assert!(warns.is_empty());
2962 match sc.expect("expected Some(Shortcode)") {
2963 Shortcode::Recent(args) => {
2964 assert_eq!(args.since.as_deref(), Some("2026-01-01"));
2965 assert_eq!(args.last.as_deref(), Some("month"));
2966 assert_eq!(args.count, Some(3));
2967 }
2968 other => panic!("expected Recent, got {other:?}"),
2969 }
2970 }
2971
2972 #[test]
2973 fn recent_with_malformed_count_yields_none_count() {
2974 let (sc, _) = parse_shortcode_block("recent", r#"{count="lots"}"#, "");
2978 match sc.expect("expected Some(Shortcode)") {
2979 Shortcode::Recent(args) => assert!(args.count.is_none()),
2980 other => panic!("expected Recent, got {other:?}"),
2981 }
2982 }
2983
2984 #[test]
2985 fn recent_body_is_trimmed() {
2986 let (sc, _) = parse_shortcode_block("recent", "", "\n hello world \n\n");
2989 match sc.expect("expected Some(Shortcode)") {
2990 Shortcode::Recent(args) => assert_eq!(args.fallback_markdown, "hello world"),
2991 other => panic!("expected Recent, got {other:?}"),
2992 }
2993 }
2994
2995 #[test]
2998 fn parses_apply_directive() {
2999 use super::super::shortcode::ShortcodeKind;
3000 use super::super::visit::has_shortcode_recursive;
3001 let doc = crate::ast::parse(":::apply\n:::\n");
3002 assert!(
3003 has_shortcode_recursive(&doc, ShortcodeKind::Apply),
3004 "expected an Apply shortcode"
3005 );
3006 }
3007
3008 #[test]
3009 fn apply_parse_bare_has_none_overrides() {
3010 let (sc, warns) = parse_shortcode_block("apply", "", "");
3011 assert!(warns.is_empty());
3012 match sc.expect("expected Some(Shortcode)") {
3013 Shortcode::Apply(args) => {
3014 assert!(args.placeholder.is_none());
3015 assert!(args.button.is_none());
3016 }
3017 other => panic!("expected Apply, got {other:?}"),
3018 }
3019 }
3020
3021 #[test]
3022 fn apply_parse_with_overrides() {
3023 let (sc, _) = parse_shortcode_block("apply", r#"{placeholder="email" button="申请"}"#, "");
3024 match sc.expect("expected Some(Shortcode)") {
3025 Shortcode::Apply(args) => {
3026 assert_eq!(args.placeholder.as_deref(), Some("email"));
3027 assert_eq!(args.button.as_deref(), Some("申请"));
3028 }
3029 other => panic!("expected Apply, got {other:?}"),
3030 }
3031 }
3032
3033 #[test]
3034 fn extracts_recent_end_to_end_with_sentinel() {
3035 let md = ":::recent {since=\"2026-04-01\" count=\"5\"}\nNo posts yet.\n:::\n";
3039 let result = extract_shortcodes(md);
3040 assert_eq!(result.extracted.len(), 1);
3041 match &result.extracted[0].shortcode {
3042 Shortcode::Recent(args) => {
3043 assert_eq!(args.since.as_deref(), Some("2026-04-01"));
3044 assert_eq!(args.count, Some(5));
3045 assert_eq!(args.fallback_markdown, "No posts yet.");
3046 }
3047 other => panic!("expected Recent, got {other:?}"),
3048 }
3049 assert!(!result.markdown_with_placeholders.contains(":::recent"));
3050 assert!(result
3051 .markdown_with_placeholders
3052 .contains(&placeholder_for(&result.nonce, 0)));
3053 }
3054}