Skip to main content

ssg/plugins/
markdown_ext.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! GitHub Flavored Markdown (GFM) extensions plugin.
5//!
6//! Pre-processes Markdown content in the `before_compile` phase to add
7//! support for GFM features that the upstream renderer does not handle:
8//!
9//! - **Tables** — `| col | col |` blocks with a `|---|---|` separator row.
10//! - **Strikethrough** — `~~text~~` becomes `<del>text</del>`.
11//! - **Task lists** — `- [ ] item` and `- [x] done` become checkbox lists.
12//! - **Footnotes** — `[^id]` references with `[^id]:` definitions.
13//!
14//! ## How it works
15//!
16//! For each `.md` file under `content_dir`, the plugin:
17//! 1. Splits the YAML/TOML frontmatter from the body so it stays untouched.
18//! 2. Walks the body line-by-line, tracking fenced code blocks so GFM
19//!    syntax inside ``` ``` ``` ``` blocks is preserved literally.
20//! 3. Detects GFM-specific blocks (tables, task lists) and renders **only
21//!    those blocks** through `pulldown-cmark` with the matching options
22//!    enabled, substituting the rendered HTML back into the source.
23//! 4. Applies an inline strikethrough transform to remaining text.
24//!
25//! Standard markdown renderers pass block-level raw HTML through
26//! unchanged, so the substituted HTML composes cleanly with whatever
27//! renderer staticdatagen runs afterwards.
28//!
29//! ## Example
30//!
31//! ```rust
32//! use ssg::plugin::PluginManager;
33//! use ssg::markdown_ext::MarkdownExtPlugin;
34//!
35//! let mut pm = PluginManager::new();
36//! pm.register(MarkdownExtPlugin);
37//! ```
38
39use 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/// Plugin that expands GFM Markdown extensions in source files.
48///
49/// Runs in `before_compile`. See the [module-level docs](self) for the
50/// full list of supported features and the transformation strategy.
51#[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
110/// Splits leading frontmatter (`--- ... ---`) from `input`.
111///
112/// Returns `(frontmatter, body)`. If no frontmatter is present the
113/// frontmatter slice is empty and the entire input is the body.
114fn 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            // Trailing newline after closing fence is optional.
123            return (&input[..fm_end], &input[fm_end..]);
124        }
125    }
126    ("", input)
127}
128
129/// Expands all GFM constructs in `input`, returning a new string.
130/// Also prefixes local images with a CDN URL if `cdn_prefix` is set.
131///
132/// # Examples
133///
134/// ```rust
135/// use ssg::markdown_ext::expand_gfm;
136///
137/// // Without GFM-specific syntax the input is preserved verbatim.
138/// let out = expand_gfm("plain text\n", None);
139/// assert_eq!(out, "plain text\n");
140/// ```
141#[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
200/// Updates fence tracking state when a fence marker is encountered.
201fn 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
216/// Processes a single non-fenced line, detecting tables, task lists, or
217/// applying strikethrough. Returns the new line index.
218fn 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
242/// Returns `true` if `body` contains any GFM-specific syntax that this
243/// plugin would transform.
244fn 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
254/// Detects whether `body` contains any GFM table block.
255fn 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
260/// Returns the fence marker (` ``` ` or `~~~`) if `line` opens or
261/// closes a fenced code block.
262fn 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
273/// Returns `true` if `header` looks like a table header followed by a
274/// `|---|---|` separator row on `separator`.
275fn is_table_header(header: &str, separator: &str) -> bool {
276    if !header.contains('|') {
277        return false;
278    }
279    is_separator_row(separator)
280}
281
282/// Returns `true` if `line` is a GFM table separator row like
283/// `| --- | :---: | ---: |`.
284fn 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
292/// Returns the index *just past* the last contiguous table line.
293fn find_table_end(lines: &[&str], start: usize) -> usize {
294    let mut end = start + 2; // header + separator
295    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
305/// Returns `true` if `line` is a task list item.
306fn 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
331/// Returns the index just past the last contiguous task list line.
332fn 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
340/// Renders `markdown` to HTML using `pulldown-cmark` with `extra`
341/// options merged in alongside the always-on strikethrough flag.
342fn 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    // Trim trailing whitespace in place instead of cloning the whole
349    // rendered block (issue #578, plan §4 3.1).
350    let trimmed_len = html.trim_end().len();
351    html.truncate(trimmed_len);
352    html
353}
354
355/// Replaces `~~text~~` with `<del>text</del>` outside of inline code spans.
356///
357/// Single pass that only allocates when a complete `~~…~~` pair is
358/// actually replaced; lines without strikethrough are returned borrowed
359/// (issue #578, plan §4 3.1). Unchanged spans are copied slice-wise, so
360/// multi-byte UTF-8 text between replacements is preserved verbatim
361/// (the previous byte-by-byte `push(byte as char)` mangled it).
362fn apply_strikethrough(line: &str) -> Cow<'_, str> {
363    let bytes = line.as_bytes();
364    // Lazily-created output buffer; `copied` tracks how much of `line`
365    // has already been flushed into it.
366    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            // Find closing `~~`.
383            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
408/// Returns the byte offset of the next `~~` after `from`, or `None`.
409const 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            // Skip inline code spans inside the strike content.
415            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!("![{alt_text}]({new_url})"));
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        // Issue #578: the no-strikethrough fast path must not allocate.
622        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        // Multiline body with no GFM constructs: byte-identical output.
639        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        // Nothing inside the fence should be transformed.
686        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        // ~~~ fences must also protect contents.
711        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 = "![Alt text](/images/pic.png)\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("![Alt text](https://cloudcdn.pro/api/transform?url=/images/pic.png&w=1600&format=webp&q=85)"));
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 = "![Alt](/img.png)";
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("![unclosed alt text", prefix),
826            "![unclosed alt text"
827        );
828
829        assert_eq!(
830            rewrite_markdown_images("![alt](unclosed-paren", prefix),
831            "![alt](unclosed-paren"
832        );
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("![alt](http://site.com/img.png)", prefix),
841            "![alt](http://site.com/img.png)"
842        );
843        assert_eq!(
844            rewrite_markdown_images("![alt](https://site.com/img.png)", prefix),
845            "![alt](https://site.com/img.png)"
846        );
847        assert_eq!(
848            rewrite_markdown_images("![alt](//site.com/img.png)", prefix),
849            "![alt](//site.com/img.png)"
850        );
851        assert_eq!(
852            rewrite_markdown_images(
853                "![alt](data:image/png;base64,123)",
854                prefix
855            ),
856            "![alt](data:image/png;base64,123)"
857        );
858    }
859
860    #[test]
861    fn split_frontmatter_unterminated_fence_treats_all_as_body() {
862        // Opening `---\n` with no closing fence at all: both `find`
863        // branches miss and the whole input is the body.
864        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        // The line-walk emits a trailing '\n'; when the source body has
873        // none, expand_gfm must pop it to stay byte-faithful.
874        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        // No space after the bullet.
881        assert!(!is_task_list_line("-[ ] task"));
882        // Invalid checkbox state character.
883        assert!(!is_task_list_line("- [y] task"));
884        // Missing closing bracket.
885        assert!(!is_task_list_line("- [x} task"));
886        // No space after the checkbox.
887        assert!(!is_task_list_line("- [x]task"));
888    }
889
890    #[test]
891    fn apply_strikethrough_skips_code_span_inside_strike_content() {
892        // The closing-delimiter scan must jump over inline code spans
893        // between the opening and closing `~~`.
894        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        // src attribute opened but never closed before `>`: the value
903        // scan finds no closing quote and the tag is left as-is.
904        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        // No blank line between the table and the next paragraph: the
917        // `find_table_end` scan must stop on the non-empty,
918        // pipe-free "Outro" line via its second OR operand, not the
919        // "line is blank" operand exercised by the sibling test above.
920        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        // `find_task_list_end` must stop as soon as a non-task-list
930        // line is encountered, rather than only ever running off the
931        // end of the `lines` slice (as the other task-list test does).
932        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        // The cdn_prefix extraction closures in `before_compile`
942        // (`ctx.config.as_ref().and_then(..).map(..)`) are only
943        // exercised when a real `SsgConfig` with `cdn_prefix` set is
944        // threaded through the plugin context — the `expand_gfm`
945        // tests above call the free function directly and never
946        // reach this code path.
947        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"), "![Alt](/images/pic.png)\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        // Content that expand_gfm rewrites, so the write site is reached.
1026        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    /// RAII guard that disables a failpoint on drop (mirrors the
1047    /// convention in `tests/fault_injection.rs`).
1048    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        // Content that expand_gfm rewrites, so the write site is reached.
1083        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}