1use crate::error::SsgError;
40use crate::plugin::{Plugin, PluginContext};
41use crate::walk::walk_files_bounded_depth;
42use crate::MAX_DIR_DEPTH;
43use pulldown_cmark::{html as cmark_html, Options, Parser};
44use std::borrow::Cow;
45use std::fs;
46
47#[allow(clippy::module_name_repetitions)]
52#[derive(Debug, Copy, Clone)]
53pub struct MarkdownExtPlugin;
54
55impl Plugin for MarkdownExtPlugin {
56 fn name(&self) -> &'static str {
57 "markdown-ext"
58 }
59
60 fn before_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
61 if !ctx.content_dir.exists() {
62 return Ok(());
63 }
64
65 let files =
66 walk_files_bounded_depth(&ctx.content_dir, "md", MAX_DIR_DEPTH)
67 .map_err(|e| SsgError::io(e, &ctx.content_dir))?;
68
69 let cdn_prefix = ctx
70 .config
71 .as_ref()
72 .and_then(|c| c.cdn_prefix.as_ref())
73 .map(|s| s.as_str());
74
75 let mut transformed = 0usize;
76 for path in &files {
77 fail_point!("markdown_ext::read", |_| {
78 Err(SsgError::Io {
79 path: path.clone(),
80 source: std::io::Error::other(
81 "injected: markdown_ext::read",
82 ),
83 })
84 });
85 let raw =
86 fs::read_to_string(path).map_err(|e| SsgError::io(e, path))?;
87
88 let new = expand_gfm(&raw, cdn_prefix);
89 if new != raw {
90 fail_point!("markdown_ext::write", |_| {
91 Err(SsgError::Io {
92 path: path.clone(),
93 source: std::io::Error::other(
94 "injected: markdown_ext::write",
95 ),
96 })
97 });
98 fs::write(path, &new).map_err(|e| SsgError::io(e, path))?;
99 transformed += 1;
100 }
101 }
102
103 if transformed > 0 {
104 log::info!("[markdown-ext] Transformed {transformed} file(s)");
105 }
106 Ok(())
107 }
108}
109
110fn split_frontmatter(input: &str) -> (&str, &str) {
115 if let Some(rest) = input.strip_prefix("---\n") {
116 if let Some(end) = rest.find("\n---\n") {
117 let fm_end = "---\n".len() + end + "\n---\n".len();
118 return (&input[..fm_end], &input[fm_end..]);
119 }
120 if let Some(end) = rest.find("\n---") {
121 let fm_end = "---\n".len() + end + "\n---".len();
122 return (&input[..fm_end], &input[fm_end..]);
124 }
125 }
126 ("", input)
127}
128
129#[must_use]
142pub fn expand_gfm(input: &str, cdn_prefix: Option<&str>) -> String {
143 let (frontmatter, body_raw) = split_frontmatter(input);
144
145 let body_owned;
146 let body = if let Some(prefix) = cdn_prefix {
147 let md_rewritten = rewrite_markdown_images(body_raw, prefix);
148 body_owned = rewrite_html_images(&md_rewritten, prefix);
149 &body_owned
150 } else {
151 body_raw
152 };
153
154 if !needs_expansion(body) {
155 if cdn_prefix.is_none() {
156 return input.to_string();
157 }
158 let mut out = String::with_capacity(frontmatter.len() + body.len());
159 out.push_str(frontmatter);
160 out.push_str(body);
161 return out;
162 }
163
164 let mut out = String::with_capacity(input.len() + 256);
165 out.push_str(frontmatter);
166
167 let lines: Vec<&str> = body.lines().collect();
168 let mut i = 0usize;
169 let mut in_fence = false;
170 let mut fence_marker: Option<&str> = None;
171
172 while i < lines.len() {
173 let line = lines[i];
174
175 if let Some(marker) = detect_fence(line) {
176 update_fence_state(&mut in_fence, &mut fence_marker, marker, line);
177 out.push_str(line);
178 out.push('\n');
179 i += 1;
180 continue;
181 }
182
183 if in_fence {
184 out.push_str(line);
185 out.push('\n');
186 i += 1;
187 continue;
188 }
189
190 i = process_gfm_line(&lines, i, &mut out);
191 }
192
193 if !body.ends_with('\n') && out.ends_with('\n') {
194 let _ = out.pop();
195 }
196
197 out
198}
199
200fn update_fence_state<'a>(
202 in_fence: &mut bool,
203 fence_marker: &mut Option<&'a str>,
204 marker: &'a str,
205 line: &str,
206) {
207 if !*in_fence {
208 *in_fence = true;
209 *fence_marker = Some(marker);
210 } else if fence_marker.is_some_and(|m| line.trim_start().starts_with(m)) {
211 *in_fence = false;
212 *fence_marker = None;
213 }
214}
215
216fn process_gfm_line(lines: &[&str], i: usize, out: &mut String) -> usize {
219 let line = lines[i];
220
221 if i + 1 < lines.len() && is_table_header(line, lines[i + 1]) {
222 let end = find_table_end(lines, i);
223 let block = lines[i..end].join("\n");
224 out.push_str(&render_with_options(&block, Options::ENABLE_TABLES));
225 out.push('\n');
226 return end;
227 }
228
229 if is_task_list_line(line) {
230 let end = find_task_list_end(lines, i);
231 let block = lines[i..end].join("\n");
232 out.push_str(&render_with_options(&block, Options::ENABLE_TASKLISTS));
233 out.push('\n');
234 return end;
235 }
236
237 out.push_str(&apply_strikethrough(line));
238 out.push('\n');
239 i + 1
240}
241
242fn needs_expansion(body: &str) -> bool {
245 if body.contains("~~") {
246 return true;
247 }
248 if body.lines().any(is_task_list_line) {
249 return true;
250 }
251 has_table(body)
252}
253
254fn has_table(body: &str) -> bool {
256 let lines: Vec<&str> = body.lines().collect();
257 lines.windows(2).any(|w| is_table_header(w[0], w[1]))
258}
259
260fn detect_fence(line: &str) -> Option<&'static str> {
263 let trimmed = line.trim_start();
264 if trimmed.starts_with("```") {
265 Some("```")
266 } else if trimmed.starts_with("~~~") {
267 Some("~~~")
268 } else {
269 None
270 }
271}
272
273fn is_table_header(header: &str, separator: &str) -> bool {
276 if !header.contains('|') {
277 return false;
278 }
279 is_separator_row(separator)
280}
281
282fn is_separator_row(line: &str) -> bool {
285 let t = line.trim();
286 if !t.contains('-') || !t.contains('|') {
287 return false;
288 }
289 t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'))
290}
291
292fn find_table_end(lines: &[&str], start: usize) -> usize {
294 let mut end = start + 2; while end < lines.len() {
296 let l = lines[end];
297 if l.trim().is_empty() || !l.contains('|') {
298 break;
299 }
300 end += 1;
301 }
302 end
303}
304
305fn is_task_list_line(line: &str) -> bool {
307 let t = line.trim_start();
308 if t.len() < 6 {
309 return false;
310 }
311 let bytes = t.as_bytes();
312 let bullet = bytes[0];
313 if !matches!(bullet, b'-' | b'*' | b'+') {
314 return false;
315 }
316 if bytes[1] != b' ' {
317 return false;
318 }
319 if bytes[2] != b'[' {
320 return false;
321 }
322 if !matches!(bytes[3], b' ' | b'x' | b'X') {
323 return false;
324 }
325 if bytes[4] != b']' {
326 return false;
327 }
328 bytes[5] == b' '
329}
330
331fn find_task_list_end(lines: &[&str], start: usize) -> usize {
333 let mut end = start;
334 while end < lines.len() && is_task_list_line(lines[end]) {
335 end += 1;
336 }
337 end
338}
339
340fn render_with_options(markdown: &str, extra: Options) -> String {
343 let mut opts = Options::ENABLE_STRIKETHROUGH;
344 opts.insert(extra);
345 let parser = Parser::new_ext(markdown, opts);
346 let mut html = String::with_capacity(markdown.len() + 64);
347 cmark_html::push_html(&mut html, parser);
348 let trimmed_len = html.trim_end().len();
351 html.truncate(trimmed_len);
352 html
353}
354
355fn apply_strikethrough(line: &str) -> Cow<'_, str> {
363 let bytes = line.as_bytes();
364 let mut out: Option<String> = None;
367 let mut copied = 0usize;
368 let mut i = 0usize;
369 let mut in_code = false;
370
371 while i < bytes.len() {
372 if bytes[i] == b'`' {
373 in_code = !in_code;
374 i += 1;
375 continue;
376 }
377 if !in_code
378 && i + 1 < bytes.len()
379 && bytes[i] == b'~'
380 && bytes[i + 1] == b'~'
381 {
382 if let Some(close) = find_strike_close(line, i + 2) {
384 let buf = out.get_or_insert_with(|| {
385 String::with_capacity(line.len() + 16)
386 });
387 buf.push_str(&line[copied..i]);
388 buf.push_str("<del>");
389 buf.push_str(&line[i + 2..close]);
390 buf.push_str("</del>");
391 i = close + 2;
392 copied = i;
393 continue;
394 }
395 }
396 i += 1;
397 }
398
399 match out {
400 Some(mut buf) => {
401 buf.push_str(&line[copied..]);
402 Cow::Owned(buf)
403 }
404 None => Cow::Borrowed(line),
405 }
406}
407
408const fn find_strike_close(line: &str, from: usize) -> Option<usize> {
410 let bytes = line.as_bytes();
411 let mut j = from;
412 while j + 1 < bytes.len() {
413 if bytes[j] == b'`' {
414 let mut k = j + 1;
416 while k < bytes.len() && bytes[k] != b'`' {
417 k += 1;
418 }
419 j = k.saturating_add(1);
420 continue;
421 }
422 if bytes[j] == b'~' && bytes[j + 1] == b'~' {
423 return Some(j);
424 }
425 j += 1;
426 }
427 None
428}
429
430fn rewrite_markdown_images(body: &str, cdn_prefix: &str) -> String {
431 let mut result = String::with_capacity(body.len());
432 let mut remaining = body;
433
434 while let Some(start_idx) = remaining.find("![") {
435 result.push_str(&remaining[..start_idx]);
436 let post_bracket = &remaining[start_idx + 2..];
437
438 let Some(close_bracket_idx) = post_bracket.find(']') else {
439 result.push_str("![");
440 remaining = post_bracket;
441 continue;
442 };
443
444 let alt_text = &post_bracket[..close_bracket_idx];
445 let post_alt = &post_bracket[close_bracket_idx + 1..];
446
447 if post_alt.starts_with('(') {
448 let Some(close_paren_idx) = post_alt.find(')') else {
449 result.push_str("![");
450 result.push_str(alt_text);
451 result.push(']');
452 remaining = post_alt;
453 continue;
454 };
455
456 let url = &post_alt[1..close_paren_idx];
457
458 let new_url = if !url.starts_with("http://")
459 && !url.starts_with("https://")
460 && !url.starts_with("//")
461 && !url.starts_with("data:")
462 {
463 format!("{}{}&w=1600&format=webp&q=85", cdn_prefix, url)
464 } else {
465 url.to_string()
466 };
467
468 result.push_str(&format!(""));
469 remaining = &post_alt[close_paren_idx + 1..];
470 } else {
471 result.push_str("![");
472 result.push_str(alt_text);
473 result.push(']');
474 remaining = post_alt;
475 }
476 }
477
478 result.push_str(remaining);
479 result
480}
481
482fn rewrite_html_images(body: &str, cdn_prefix: &str) -> String {
483 let mut result = String::with_capacity(body.len());
484 let mut remaining = body;
485
486 while let Some(start_idx) = remaining.find("<img ") {
487 result.push_str(&remaining[..start_idx]);
488 let tag_content = &remaining[start_idx..];
489
490 let Some(end_idx) = tag_content.find('>') else {
491 result.push_str(remaining);
492 return result;
493 };
494
495 let tag_inner = &tag_content[..end_idx + 1];
496 let mut rewritten_tag = tag_inner.to_string();
497
498 let mut src_val = None;
499 for quote in ['"', '\''] {
500 let pattern = format!("src={quote}");
501 if let Some(pos) = tag_inner.find(&pattern) {
502 let val_start = pos + pattern.len();
503 if let Some(val_end) = tag_inner[val_start..].find(quote) {
504 src_val = Some((
505 val_start,
506 val_end,
507 tag_inner[val_start..val_start + val_end].to_string(),
508 quote,
509 ));
510 break;
511 }
512 }
513 }
514
515 if let Some((val_start, val_end, url, _quote)) = src_val {
516 if !url.starts_with("http://")
517 && !url.starts_with("https://")
518 && !url.starts_with("//")
519 && !url.starts_with("data:")
520 {
521 let new_url =
522 format!("{}{}&w=1600&format=webp&q=85", cdn_prefix, url);
523 let before = &rewritten_tag[..val_start];
524 let after = &rewritten_tag[val_start + val_end..];
525 rewritten_tag = format!("{before}{new_url}{after}");
526 }
527 }
528
529 result.push_str(&rewritten_tag);
530 remaining = &tag_content[end_idx + 1..];
531 }
532
533 result.push_str(remaining);
534 result
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::plugin::Plugin;
541 use tempfile::tempdir;
542
543 #[test]
544 fn split_frontmatter_extracts_yaml_block() {
545 let input = "---\ntitle: Hello\n---\nBody here\n";
546 let (fm, body) = split_frontmatter(input);
547 assert_eq!(fm, "---\ntitle: Hello\n---\n");
548 assert_eq!(body, "Body here\n");
549 }
550
551 #[test]
552 fn split_frontmatter_returns_empty_when_absent() {
553 let input = "Just a body\nwith two lines\n";
554 let (fm, body) = split_frontmatter(input);
555 assert_eq!(fm, "");
556 assert_eq!(body, input);
557 }
558
559 #[test]
560 fn needs_expansion_detects_strikethrough() {
561 assert!(needs_expansion("hello ~~world~~"));
562 }
563
564 #[test]
565 fn needs_expansion_detects_task_list() {
566 assert!(needs_expansion("- [ ] todo\n- [x] done\n"));
567 }
568
569 #[test]
570 fn needs_expansion_detects_table() {
571 let body = "| a | b |\n|---|---|\n| 1 | 2 |\n";
572 assert!(needs_expansion(body));
573 }
574
575 #[test]
576 fn needs_expansion_returns_false_for_plain_markdown() {
577 assert!(!needs_expansion("# Heading\n\nA paragraph.\n"));
578 }
579
580 #[test]
581 fn is_separator_row_accepts_aligned_separators() {
582 assert!(is_separator_row("|---|---|"));
583 assert!(is_separator_row("| :--- | :---: | ---: |"));
584 assert!(!is_separator_row("| a | b |"));
585 assert!(!is_separator_row("plain text"));
586 }
587
588 #[test]
589 fn is_task_list_line_recognises_open_and_done() {
590 assert!(is_task_list_line("- [ ] todo"));
591 assert!(is_task_list_line("- [x] done"));
592 assert!(is_task_list_line("- [X] done"));
593 assert!(is_task_list_line(" * [ ] indented"));
594 assert!(!is_task_list_line("- regular bullet"));
595 assert!(!is_task_list_line("[ ] no bullet"));
596 }
597
598 #[test]
599 fn apply_strikethrough_wraps_simple_pair() {
600 assert_eq!(
601 apply_strikethrough("hello ~~world~~ done"),
602 "hello <del>world</del> done"
603 );
604 }
605
606 #[test]
607 fn apply_strikethrough_skips_inside_code_span() {
608 assert_eq!(
609 apply_strikethrough("`~~not~~` but ~~yes~~"),
610 "`~~not~~` but <del>yes</del>"
611 );
612 }
613
614 #[test]
615 fn apply_strikethrough_leaves_unmatched_tildes() {
616 assert_eq!(apply_strikethrough("just ~~ here"), "just ~~ here");
617 }
618
619 #[test]
620 fn apply_strikethrough_borrows_when_no_delimiter() {
621 assert!(matches!(
623 apply_strikethrough("plain line, nothing to do"),
624 Cow::Borrowed(_)
625 ));
626 }
627
628 #[test]
629 fn apply_strikethrough_preserves_multibyte_text() {
630 assert_eq!(
631 apply_strikethrough("café ~~ancien~~ nouveau — été"),
632 "café <del>ancien</del> nouveau — été"
633 );
634 }
635
636 #[test]
637 fn expand_gfm_multiline_without_strikethrough_unchanged() {
638 let input = "# Title\n\nfirst plain line\nsecond plain line\n\nthird paragraph line\n";
640 assert_eq!(expand_gfm(input, None), input);
641 }
642
643 #[test]
644 fn expand_gfm_mixed_lines_only_transforms_strikethrough() {
645 let input = "keep this line\n~~gone~~ stays\nanother plain line\n";
646 assert_eq!(
647 expand_gfm(input, None),
648 "keep this line\n<del>gone</del> stays\nanother plain line\n"
649 );
650 }
651
652 #[test]
653 fn expand_gfm_renders_table_block() {
654 let input = "Intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nOutro\n";
655 let out = expand_gfm(input, None);
656 assert!(out.contains("<table>"), "got: {out}");
657 assert!(out.contains("<th>a</th>"));
658 assert!(out.contains("<td>1</td>"));
659 assert!(out.contains("Intro"));
660 assert!(out.contains("Outro"));
661 }
662
663 #[test]
664 fn expand_gfm_renders_task_list_block() {
665 let input = "- [ ] one\n- [x] two\n";
666 let out = expand_gfm(input, None);
667 assert!(out.contains("<ul>"), "got: {out}");
668 assert!(out.contains("type=\"checkbox\""));
669 assert!(out.contains("disabled"));
670 assert!(out.contains("checked"));
671 }
672
673 #[test]
674 fn expand_gfm_renders_strikethrough_inline() {
675 let input = "Some ~~old~~ new text\n";
676 let out = expand_gfm(input, None);
677 assert_eq!(out, "Some <del>old</del> new text\n");
678 }
679
680 #[test]
681 fn expand_gfm_preserves_fenced_code_contents() {
682 let input =
683 "```\n| a | b |\n|---|---|\n~~not strike~~\n- [ ] not task\n```\n";
684 let out = expand_gfm(input, None);
685 assert!(out.contains("| a | b |"));
687 assert!(out.contains("~~not strike~~"));
688 assert!(out.contains("- [ ] not task"));
689 assert!(!out.contains("<table>"));
690 assert!(!out.contains("<del>"));
691 }
692
693 #[test]
694 fn expand_gfm_preserves_frontmatter_unchanged() {
695 let input = "---\ntitle: Test\n---\n~~strike~~ this\n";
696 let out = expand_gfm(input, None);
697 assert!(out.starts_with("---\ntitle: Test\n---\n"));
698 assert!(out.contains("<del>strike</del>"));
699 }
700
701 #[test]
702 fn expand_gfm_returns_input_unchanged_when_no_features() {
703 let input = "# Heading\n\nA paragraph with no extensions.\n";
704 let out = expand_gfm(input, None);
705 assert_eq!(out, input);
706 }
707
708 #[test]
709 fn expand_gfm_handles_tildes_in_tilde_fenced_code() {
710 let input = "~~~\n~~text~~\n~~~\n";
712 let out = expand_gfm(input, None);
713 assert!(out.contains("~~text~~"));
714 assert!(!out.contains("<del>"));
715 }
716
717 #[test]
718 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
719 fn plugin_transforms_markdown_files_in_place() {
720 crate::test_support::init_logger();
721 let dir = tempdir().unwrap();
722 let content = dir.path().join("content");
723 fs::create_dir_all(&content).unwrap();
724 fs::write(
725 content.join("post.md"),
726 "---\ntitle: Test\n---\n~~old~~ new\n",
727 )
728 .unwrap();
729 fs::write(content.join("untouched.md"), "# Plain\n\nNothing fancy.\n")
730 .unwrap();
731
732 let ctx =
733 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
734 MarkdownExtPlugin.before_compile(&ctx).unwrap();
735
736 let post = fs::read_to_string(content.join("post.md")).unwrap();
737 assert!(post.contains("<del>old</del>"));
738 assert!(post.starts_with("---\ntitle: Test\n---\n"));
739
740 let untouched =
741 fs::read_to_string(content.join("untouched.md")).unwrap();
742 assert_eq!(untouched, "# Plain\n\nNothing fancy.\n");
743 }
744
745 #[test]
746 fn plugin_returns_ok_when_content_dir_missing() {
747 let dir = tempdir().unwrap();
748 let ctx = PluginContext::new(
749 &dir.path().join("missing"),
750 dir.path(),
751 dir.path(),
752 dir.path(),
753 );
754 MarkdownExtPlugin.before_compile(&ctx).unwrap();
755 }
756
757 #[test]
758 fn plugin_name_is_markdown_ext() {
759 assert_eq!(MarkdownExtPlugin.name(), "markdown-ext");
760 }
761
762 #[test]
763 fn test_cdn_prefix_rewrites_images() {
764 let input = "\n<img src=\"/images/pic2.png\" alt=\"HTML img\">";
765 let prefix = "https://cloudcdn.pro/api/transform?url=";
766 let out = expand_gfm(input, Some(prefix));
767 assert!(out.contains(""));
768 assert!(out.contains("src=\"https://cloudcdn.pro/api/transform?url=/images/pic2.png&w=1600&format=webp&q=85\""));
769 }
770
771 #[test]
772 fn split_frontmatter_optional_trailing_newline() {
773 let input = "---\ntitle: Hello\n---Body here";
774 let (fm, body) = split_frontmatter(input);
775 assert_eq!(fm, "---\ntitle: Hello\n---");
776 assert_eq!(body, "Body here");
777 }
778
779 #[test]
780 fn test_cdn_prefix_no_gfm_expansion() {
781 let input = "";
782 let prefix = "https://cdn.example.com/";
783 let out = expand_gfm(input, Some(prefix));
784 assert!(out.contains("https://cdn.example.com//img.png"));
785 }
786
787 #[test]
788 fn test_rewrite_html_images_edge_cases() {
789 let prefix = "https://cdn/";
790
791 let out1 = rewrite_html_images("<img src='foo.png'>", prefix);
792 assert!(out1.contains("https://cdn/foo.png"));
793
794 assert_eq!(
795 rewrite_html_images("<img src=\"http://foo.com/a.png\">", prefix),
796 "<img src=\"http://foo.com/a.png\">"
797 );
798 assert_eq!(
799 rewrite_html_images("<img src=\"https://foo.com/a.png\">", prefix),
800 "<img src=\"https://foo.com/a.png\">"
801 );
802 assert_eq!(
803 rewrite_html_images("<img src=\"//foo.com/a.png\">", prefix),
804 "<img src=\"//foo.com/a.png\">"
805 );
806 assert_eq!(
807 rewrite_html_images(
808 "<img src=\"data:image/png;base64,...\">",
809 prefix
810 ),
811 "<img src=\"data:image/png;base64,...\">"
812 );
813
814 assert_eq!(
815 rewrite_html_images("<img src=\"foo.png\"", prefix),
816 "<img src=\"foo.png\""
817 );
818 }
819
820 #[test]
821 fn test_rewrite_markdown_images_edge_cases() {
822 let prefix = "https://cdn/";
823
824 assert_eq!(
825 rewrite_markdown_images(",
831 ";
833
834 assert_eq!(
835 rewrite_markdown_images("![alt] no paren", prefix),
836 "![alt] no paren"
837 );
838
839 assert_eq!(
840 rewrite_markdown_images("", prefix),
841 ""
842 );
843 assert_eq!(
844 rewrite_markdown_images("", prefix),
845 ""
846 );
847 assert_eq!(
848 rewrite_markdown_images("", prefix),
849 ""
850 );
851 assert_eq!(
852 rewrite_markdown_images(
853 "",
854 prefix
855 ),
856 ""
857 );
858 }
859
860 #[test]
861 fn split_frontmatter_unterminated_fence_treats_all_as_body() {
862 let input = "---\ntitle: broken frontmatter with no closing fence";
865 let (fm, body) = split_frontmatter(input);
866 assert_eq!(fm, "");
867 assert_eq!(body, input);
868 }
869
870 #[test]
871 fn expand_gfm_without_trailing_newline_pops_added_newline() {
872 let out = expand_gfm("~~a~~", None);
875 assert_eq!(out, "<del>a</del>");
876 }
877
878 #[test]
879 fn is_task_list_line_rejects_malformed_checkbox_syntax() {
880 assert!(!is_task_list_line("-[ ] task"));
882 assert!(!is_task_list_line("- [y] task"));
884 assert!(!is_task_list_line("- [x} task"));
886 assert!(!is_task_list_line("- [x]task"));
888 }
889
890 #[test]
891 fn apply_strikethrough_skips_code_span_inside_strike_content() {
892 assert_eq!(
895 apply_strikethrough("~~has `tick` inside~~"),
896 "<del>has `tick` inside</del>"
897 );
898 }
899
900 #[test]
901 fn rewrite_html_images_unterminated_src_quote_left_unchanged() {
902 let input = "<img src=\"unterminated.png>";
905 assert_eq!(rewrite_html_images(input, "https://cdn/"), input);
906 }
907
908 #[test]
909 fn rewrite_html_images_without_src_attribute_left_unchanged() {
910 let input = "<img alt=\"no source here\">";
911 assert_eq!(rewrite_html_images(input, "https://cdn/"), input);
912 }
913
914 #[test]
915 fn expand_gfm_table_ends_without_blank_line_separator() {
916 let input = "Intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\nOutro\n";
921 let out = expand_gfm(input, None);
922 assert!(out.contains("<table>"), "got: {out}");
923 assert!(out.contains("<td>1</td>"));
924 assert!(out.contains("Outro"));
925 }
926
927 #[test]
928 fn expand_gfm_task_list_followed_by_plain_text() {
929 let input = "- [ ] one\n- [x] two\nplain paragraph\n";
933 let out = expand_gfm(input, None);
934 assert!(out.contains("<ul>"), "got: {out}");
935 assert!(out.contains("plain paragraph"));
936 }
937
938 #[test]
939 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
940 fn before_compile_applies_cdn_prefix_from_config() {
941 use crate::cmd::SsgConfig;
948 let dir = tempdir().unwrap();
949 let content = dir.path().join("content");
950 fs::create_dir_all(&content).unwrap();
951 fs::write(content.join("post.md"), "\n")
952 .unwrap();
953
954 let cfg = SsgConfig::builder()
955 .cdn_prefix(Some("https://cdn.example.com/".to_string()))
956 .build()
957 .expect("config");
958 let ctx = PluginContext::with_config(
959 &content,
960 dir.path(),
961 dir.path(),
962 dir.path(),
963 cfg,
964 );
965 MarkdownExtPlugin.before_compile(&ctx).unwrap();
966
967 let post = fs::read_to_string(content.join("post.md")).unwrap();
968 assert!(
969 post.contains("https://cdn.example.com//images/pic.png"),
970 "got: {post}"
971 );
972 }
973
974 #[cfg(unix)]
975 #[test]
976 fn before_compile_propagates_walk_error_from_unreadable_subdir() {
977 use std::os::unix::fs::PermissionsExt;
978
979 let dir = tempdir().unwrap();
980 let content = dir.path().join("content");
981 let locked = content.join("locked");
982 fs::create_dir_all(&locked).unwrap();
983 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
984 .unwrap();
985
986 let ctx =
987 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
988 let result = MarkdownExtPlugin.before_compile(&ctx);
989
990 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
991 .unwrap();
992 assert!(result.is_err(), "unreadable subdir must surface an error");
993 }
994
995 #[cfg(unix)]
996 #[test]
997 fn before_compile_real_read_permission_denied_returns_io_error() {
998 use std::os::unix::fs::PermissionsExt;
999
1000 let dir = tempdir().unwrap();
1001 let content = dir.path().join("content");
1002 fs::create_dir_all(&content).unwrap();
1003 let file = content.join("locked.md");
1004 fs::write(&file, "# Hi").unwrap();
1005 fs::set_permissions(&file, fs::Permissions::from_mode(0o000)).unwrap();
1006
1007 let ctx =
1008 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
1009 let result = MarkdownExtPlugin.before_compile(&ctx);
1010
1011 fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
1012 let err = result.expect_err("permission-denied read must surface");
1013 assert!(!format!("{err}").is_empty());
1014 }
1015
1016 #[cfg(unix)]
1017 #[test]
1018 fn before_compile_real_write_permission_denied_returns_io_error() {
1019 use std::os::unix::fs::PermissionsExt;
1020
1021 let dir = tempdir().unwrap();
1022 let content = dir.path().join("content");
1023 fs::create_dir_all(&content).unwrap();
1024 let file = content.join("post.md");
1025 fs::write(&file, "~~old~~ new\n").unwrap();
1027 fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap();
1028
1029 let ctx =
1030 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
1031 let result = MarkdownExtPlugin.before_compile(&ctx);
1032
1033 fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
1034 let err = result.expect_err("permission-denied write must surface");
1035 assert!(!format!("{err}").is_empty());
1036 }
1037}
1038
1039#[cfg(all(test, feature = "test-fault-injection"))]
1040mod fault_tests {
1041 use super::*;
1042 use crate::plugin::{Plugin, PluginContext};
1043 use serial_test::serial;
1044 use tempfile::tempdir;
1045
1046 struct FailGuard(&'static str);
1049
1050 impl Drop for FailGuard {
1051 fn drop(&mut self) {
1052 let _ = fail::cfg(self.0, "off");
1053 }
1054 }
1055
1056 #[test]
1057 #[serial]
1058 fn before_compile_read_failpoint_propagates() {
1059 let _guard = FailGuard("markdown_ext::read");
1060 fail::cfg("markdown_ext::read", "return").expect("activate failpoint");
1061
1062 let dir = tempdir().unwrap();
1063 let content = dir.path().to_path_buf();
1064 fs::write(content.join("post.md"), "# Hi").unwrap();
1065
1066 let ctx =
1067 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
1068 let err = MarkdownExtPlugin
1069 .before_compile(&ctx)
1070 .expect_err("injected read failure must propagate");
1071 assert!(format!("{err:?}").contains("injected: markdown_ext::read"));
1072 }
1073
1074 #[test]
1075 #[serial]
1076 fn before_compile_write_failpoint_propagates() {
1077 let _guard = FailGuard("markdown_ext::write");
1078 fail::cfg("markdown_ext::write", "return").expect("activate failpoint");
1079
1080 let dir = tempdir().unwrap();
1081 let content = dir.path().to_path_buf();
1082 fs::write(content.join("post.md"), "~~old~~ new\n").unwrap();
1084
1085 let ctx =
1086 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
1087 let err = MarkdownExtPlugin
1088 .before_compile(&ctx)
1089 .expect_err("injected write failure must propagate");
1090 assert!(format!("{err:?}").contains("injected: markdown_ext::write"));
1091 }
1092}