Skip to main content

ssg/audit/gates/
markdownlint.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Markdown linting and formatting gate (native Rust, no shell-out).
5//!
6//! This is the MVP rule-set tracked under issue #549. It covers the
7//! `markdownlint`-style rules that catch the bulk of authoring drift
8//! without requiring a full upstream `markdownlint-rs` integration —
9//! the depth-of-coverage roadmap calls out adding the upstream port
10//! once it stabilises for 2026 toolchains.
11//!
12//! Rules enforced:
13//! - **MD009** — no trailing whitespace at end of line.
14//! - **MD010** — no hard tabs (use spaces).
15//! - **MD025** — at most one top-level `#` heading per file.
16//! - **MD034** — no bare `http://` / `https://` URLs (must be in
17//!   `[text](url)` or `<url>` form).
18//! - **MD041** — file must start with a top-level heading (or YAML
19//!   frontmatter followed by one).
20//!
21//! Scans the **content** directory (`<site>/../content` or the sibling
22//! `content/` dir). When neither exists the gate emits an info note
23//! and skips — matches the behaviour of the docs gates.
24
25use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
26use crate::walk::walk_files;
27use std::path::Path;
28
29const NAME: &str = "markdownlint";
30
31/// Markdown linting + formatting gate.
32///
33/// # Examples
34///
35/// ```
36/// use ssg::audit::AuditGate;
37/// use ssg::audit::gates::markdownlint::MarkdownlintGate;
38/// assert_eq!(MarkdownlintGate.name(), "markdownlint");
39/// ```
40#[derive(Debug, Clone, Copy)]
41pub struct MarkdownlintGate;
42
43impl AuditGate for MarkdownlintGate {
44    fn name(&self) -> &'static str {
45        NAME
46    }
47
48    fn explain(&self) -> &'static str {
49        "Lints Markdown source under content/ for: trailing whitespace \
50         (MD009), hard tabs (MD010), multiple H1s (MD025), bare URLs \
51         (MD034), and missing top-level heading (MD041). When content/ \
52         is absent the gate skips with an info note."
53    }
54
55    fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
56        let mut findings = Vec::new();
57        let content_dir = locate_content_dir(&site.root);
58        let Some(dir) = content_dir else {
59            findings.push(
60                Finding::new(
61                    NAME,
62                    Severity::Info,
63                    "No content/ directory found alongside site root; gate skipped",
64                )
65                .with_code("MD-INPUT-MISSING"),
66            );
67            return findings;
68        };
69
70        let files = walk_files(&dir, "md").unwrap_or_default();
71        for path in &files {
72            let Ok(text) = std::fs::read_to_string(path) else {
73                continue;
74            };
75            let rel = path
76                .strip_prefix(&dir)
77                .unwrap_or(path)
78                .to_string_lossy()
79                .into_owned();
80            lint_markdown(&text, &rel, &mut findings);
81        }
82        findings
83    }
84}
85
86fn locate_content_dir(root: &Path) -> Option<std::path::PathBuf> {
87    let direct = root.join("content");
88    if direct.is_dir() {
89        return Some(direct);
90    }
91    let sibling = root.parent()?.join("content");
92    sibling.is_dir().then_some(sibling)
93}
94
95fn lint_markdown(text: &str, rel: &str, findings: &mut Vec<Finding>) {
96    let mut h1_count = 0usize;
97    let mut in_code_block = false;
98    let fm_lines = frontmatter_line_count(text);
99
100    // MD041: first non-frontmatter, non-blank line must be `# `.
101    // A frontmatter `title:` satisfies the requirement too — mirrors
102    // upstream markdownlint's `front_matter_title` behaviour, since
103    // the H1 is template-provided from the title on such pages.
104    let first_content_line = first_heading_candidate(text);
105    if let Some(line) = first_content_line {
106        if !line.starts_with("# ") && !frontmatter_has_title(text, fm_lines) {
107            findings.push(
108                Finding::new(
109                    NAME,
110                    Severity::Warn,
111                    "File does not begin with a top-level (#) heading",
112                )
113                .with_code("MD041")
114                .with_path(rel.to_string()),
115            );
116        }
117    }
118
119    for (idx, raw_line) in text.lines().enumerate() {
120        let line_no = idx + 1;
121        // YAML frontmatter is not Markdown — never lint it (URL values
122        // like `permalink:` are not bare-URL prose, `#` is a comment).
123        if idx < fm_lines {
124            continue;
125        }
126        if raw_line.trim_start().starts_with("```") {
127            in_code_block = !in_code_block;
128            continue;
129        }
130        if in_code_block {
131            continue;
132        }
133        if raw_line.contains('\t') {
134            findings.push(
135                Finding::new(
136                    NAME,
137                    Severity::Warn,
138                    format!("L{line_no}: hard tab character"),
139                )
140                .with_code("MD010")
141                .with_path(rel.to_string()),
142            );
143        }
144        if raw_line.ends_with(' ') && !raw_line.trim().is_empty() {
145            findings.push(
146                Finding::new(
147                    NAME,
148                    Severity::Warn,
149                    format!("L{line_no}: trailing whitespace"),
150                )
151                .with_code("MD009")
152                .with_path(rel.to_string()),
153            );
154        }
155        if raw_line.starts_with("# ") {
156            h1_count += 1;
157        }
158        // MD034: bare URL not inside (...) or <...>
159        if let Some(idx2) = raw_line
160            .find("http://")
161            .or_else(|| raw_line.find("https://"))
162        {
163            let before = &raw_line[..idx2];
164            // `split_whitespace().next()` on a slice that itself starts
165            // at a non-whitespace byte (`idx2` points at `h`) always
166            // yields the run up to the next whitespace char (or EOF),
167            // i.e. the trailing `>` / `)` delimiter of `<https://x>` or
168            // `(https://x)` is swallowed into the token itself — so the
169            // closing delimiter only ever needs to be checked at the
170            // end of the token.
171            let after_url =
172                raw_line[idx2..].split_whitespace().next().unwrap_or("");
173            let in_bracket = before.contains('(') && after_url.ends_with(')');
174            let in_lt = before.ends_with('<') && after_url.ends_with('>');
175            let in_link = before.ends_with("](");
176            if !in_bracket && !in_lt && !in_link {
177                findings.push(
178                    Finding::new(
179                        NAME,
180                        Severity::Warn,
181                        format!("L{line_no}: bare URL — wrap in <…> or []()"),
182                    )
183                    .with_code("MD034")
184                    .with_path(rel.to_string()),
185                );
186            }
187        }
188    }
189
190    if h1_count > 1 {
191        findings.push(
192            Finding::new(
193                NAME,
194                Severity::Warn,
195                format!(
196                    "File has {h1_count} top-level (#) headings; expected 1"
197                ),
198            )
199            .with_code("MD025")
200            .with_path(rel.to_string()),
201        );
202    }
203}
204
205/// Returns the number of leading lines occupied by YAML frontmatter
206/// (opening `---`, body, closing `---` inclusive), or `0` when the
207/// file has none or the fence never closes.
208fn frontmatter_line_count(text: &str) -> usize {
209    let mut lines = text.lines();
210    if lines.next().map(str::trim) != Some("---") {
211        return 0;
212    }
213    let mut count = 1;
214    for line in lines {
215        count += 1;
216        if line.trim() == "---" {
217            return count;
218        }
219    }
220    0
221}
222
223/// `true` when the frontmatter block declares a `title:` (or `title=`)
224/// key — upstream markdownlint's `front_matter_title` default.
225fn frontmatter_has_title(text: &str, fm_lines: usize) -> bool {
226    fm_lines > 0
227        && text.lines().take(fm_lines).skip(1).any(|line| {
228            let lower = line.trim_start().to_ascii_lowercase();
229            lower.starts_with("title:") || lower.starts_with("title=")
230        })
231}
232
233fn first_heading_candidate(text: &str) -> Option<&str> {
234    let mut lines = text.lines();
235    if let Some(first) = lines.next() {
236        if first.trim() == "---" {
237            // Skip frontmatter block
238            for line in &mut lines {
239                if line.trim() == "---" {
240                    break;
241                }
242            }
243        } else if !first.trim().is_empty() {
244            return Some(first);
245        }
246    }
247    lines.find(|line| !line.trim().is_empty())
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::path::PathBuf;
254
255    fn site_with_content(files: &[(&str, &str)]) -> Site {
256        let tmp = tempfile::tempdir().unwrap();
257        let site_root = tmp.path().join("public");
258        let content = tmp.path().join("content");
259        std::fs::create_dir_all(&site_root).unwrap();
260        std::fs::create_dir_all(&content).unwrap();
261        for (rel, body) in files {
262            let p = content.join(rel);
263            if let Some(parent) = p.parent() {
264                std::fs::create_dir_all(parent).unwrap();
265            }
266            std::fs::write(&p, body).unwrap();
267        }
268        std::mem::forget(tmp);
269        Site {
270            root: site_root,
271            html_files: Vec::new(),
272        }
273    }
274
275    #[test]
276    fn passing_markdown_is_clean() {
277        let s = site_with_content(&[(
278            "index.md",
279            "# Title\n\nThis is a paragraph.\n\n[link](https://example.com)\n",
280        )]);
281        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
282        assert!(f.is_empty(), "got {f:?}");
283    }
284
285    #[test]
286    fn bad_markdown_is_flagged() {
287        let s = site_with_content(&[(
288            "bad.md",
289            "## not h1\n\nhttps://bare-url.test\n\ttab line\ntrailing ws \n",
290        )]);
291        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
292        let codes: Vec<_> =
293            f.iter().filter_map(|x| x.code.as_deref()).collect();
294        assert!(codes.contains(&"MD034"));
295        assert!(codes.contains(&"MD010"));
296        assert!(codes.contains(&"MD009"));
297        assert!(codes.contains(&"MD041"));
298    }
299
300    #[test]
301    fn absent_content_dir_emits_info_skip() {
302        let s = Site {
303            root: PathBuf::from("/nonexistent/dir/that/does/not/exist"),
304            html_files: Vec::new(),
305        };
306        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
307        assert_eq!(f.len(), 1);
308        assert_eq!(f[0].code.as_deref(), Some("MD-INPUT-MISSING"));
309    }
310
311    #[test]
312    fn multiple_h1_headings_trip_md025() {
313        let s =
314            site_with_content(&[("doc.md", "# first\n\n# second\n\nbody\n")]);
315        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
316        assert!(f.iter().any(|x| x.code.as_deref() == Some("MD025")));
317    }
318
319    #[test]
320    fn frontmatter_then_heading_is_clean() {
321        let s = site_with_content(&[(
322            "doc.md",
323            "---\ntitle: x\n---\n\n# Heading\n\nbody.\n",
324        )]);
325        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
326        assert!(f.is_empty(), "frontmatter wrapper should be clean: {f:?}");
327    }
328
329    #[test]
330    fn bare_url_inside_markdown_link_is_silent() {
331        // Trailing whitespace keeps `f` non-empty (MD009) so the
332        // no-MD034 predicate actually evaluates.
333        let s = site_with_content(&[(
334            "doc.md",
335            "# title\n\n[click](https://example.com)\nws \n",
336        )]);
337        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
338        assert!(f.iter().any(|x| x.code.as_deref() == Some("MD009")));
339        assert!(f.iter().all(|x| x.code.as_deref() != Some("MD034")));
340    }
341
342    #[test]
343    fn trailing_whitespace_outside_code_block_trips_md009() {
344        let s = site_with_content(&[(
345            "doc.md",
346            "# title\n\nline with trailing space \n",
347        )]);
348        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
349        assert!(f.iter().any(|x| x.code.as_deref() == Some("MD009")));
350    }
351
352    #[test]
353    fn code_block_contents_are_not_linted() {
354        // Trailing whitespace after the fence keeps at least one benign
355        // finding in `f`, so the exemption predicate actually evaluates.
356        let s = site_with_content(&[(
357            "doc.md",
358            "# title\n\n```\n\ttab in code\nhttps://bare.in-code\n```\n\nws \n",
359        )]);
360        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
361        assert!(
362            f.iter().any(|x| x.code.as_deref() == Some("MD009")),
363            "outside-fence lint must still fire: {f:?}"
364        );
365        // Inside the fenced block: MD010 and MD034 should NOT fire.
366        assert!(
367            f.iter().all(|x| x.code.as_deref() != Some("MD010")
368                && x.code.as_deref() != Some("MD034")),
369            "code-block contents should be exempt: {f:?}"
370        );
371    }
372
373    #[test]
374    fn missing_top_heading_flagged_md041() {
375        let s = site_with_content(&[("doc.md", "Just a paragraph.\n")]);
376        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
377        assert!(f.iter().any(|x| x.code.as_deref() == Some("MD041")));
378    }
379
380    #[test]
381    fn empty_file_produces_no_md041() {
382        // first_heading_candidate returns None on a fully empty file.
383        // The sibling file's MD009 keeps `f` non-empty so the
384        // no-MD041 predicate actually evaluates.
385        let s =
386            site_with_content(&[("empty.md", ""), ("ws.md", "# t\n\nws \n")]);
387        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
388        assert!(f.iter().any(|x| x.code.as_deref() == Some("MD009")));
389        assert!(f.iter().all(|x| x.code.as_deref() != Some("MD041")));
390    }
391
392    #[test]
393    fn sibling_content_dir_layout_is_discovered() {
394        // Use a `<root>/../content` layout, mimicking real ssg sites.
395        // Trailing whitespace produces a finding, proving the file
396        // was actually scanned (and keeping the predicate evaluated).
397        let s = site_with_content(&[("doc.md", "# ok\n\nws \n")]);
398        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
399        assert!(
400            f.iter()
401                .all(|x| x.code.as_deref() != Some("MD-INPUT-MISSING")),
402            "sibling content/ should be discovered: {f:?}"
403        );
404    }
405
406    #[test]
407    fn frontmatter_url_values_are_not_bare_urls() {
408        // Regression: ~110 false MD034 on frontmatter values like
409        // `permalink: "https://…"` — YAML is not Markdown prose.
410        let s = site_with_content(&[(
411            "doc.md",
412            "---\ntitle: \"X\"\npermalink: \"https://example.com/x/\"\n\
413             url: https://example.com\natom: \"https://example.com/atom.xml\"\n\
414             ---\n\n# Heading\n\nbody. \n",
415        )]);
416        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
417        assert!(
418            f.iter().all(|x| x.code.as_deref() != Some("MD034")),
419            "frontmatter URLs must not trip MD034: {f:?}"
420        );
421    }
422
423    #[test]
424    fn bare_url_in_body_still_flagged_after_frontmatter() {
425        // True positive preserved: bare URLs in body text still fire.
426        let s = site_with_content(&[(
427            "doc.md",
428            "---\ntitle: \"X\"\n---\n\n# Heading\n\nSee https://bare.example\n",
429        )]);
430        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
431        assert!(
432            f.iter().any(|x| x.code.as_deref() == Some("MD034")),
433            "body bare URL must still trip MD034: {f:?}"
434        );
435    }
436
437    #[test]
438    fn frontmatter_hard_tabs_and_trailing_ws_are_exempt() {
439        // The body bare URL guarantees `f` is non-empty, so the
440        // exemption predicate below actually evaluates per finding.
441        let s = site_with_content(&[(
442            "doc.md",
443            "---\ntitle: \"X\"\nkey:\t\"tabbed\"   \n---\n\n# Heading\n\n\
444             see https://bare.example\n",
445        )]);
446        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
447        assert!(
448            f.iter().any(|x| x.code.as_deref() == Some("MD034")),
449            "body lint must still fire: {f:?}"
450        );
451        assert!(
452            f.iter().all(|x| x.code.as_deref() != Some("MD010")
453                && x.code.as_deref() != Some("MD009")),
454            "frontmatter must be exempt from MD009/MD010: {f:?}"
455        );
456    }
457
458    #[test]
459    fn frontmatter_yaml_comment_is_not_an_h1() {
460        // A YAML `# comment` inside frontmatter must not count toward
461        // MD025's H1 tally.
462        let s = site_with_content(&[(
463            "doc.md",
464            "---\n# yaml comment\ntitle: \"X\"\n---\n\n# Only H1\n\nbody \n",
465        )]);
466        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
467        assert!(
468            f.iter().all(|x| x.code.as_deref() != Some("MD025")),
469            "yaml comments are not headings: {f:?}"
470        );
471    }
472
473    #[test]
474    fn frontmatter_title_satisfies_md041() {
475        // Upstream `front_matter_title` behaviour: the H1 is
476        // template-provided from `title:`, so no MD041.
477        let s = site_with_content(&[(
478            "doc.md",
479            "---\ntitle: \"Threshold\"\n---\n\n## Section heading\n\nbody \n",
480        )]);
481        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
482        assert!(
483            f.iter().all(|x| x.code.as_deref() != Some("MD041")),
484            "frontmatter title: must satisfy MD041: {f:?}"
485        );
486    }
487
488    #[test]
489    fn missing_title_and_h1_still_trips_md041() {
490        // True positive preserved: no `title:` and no leading `# `.
491        let s = site_with_content(&[(
492            "doc.md",
493            "---\nauthor: \"A\"\n---\n\n## Not an H1\n\nbody\n",
494        )]);
495        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
496        assert!(
497            f.iter().any(|x| x.code.as_deref() == Some("MD041")),
498            "no title + no H1 must still trip MD041: {f:?}"
499        );
500    }
501
502    #[test]
503    fn frontmatter_helpers_edge_cases() {
504        assert_eq!(frontmatter_line_count("no frontmatter\n"), 0);
505        assert_eq!(frontmatter_line_count("---\ntitle: x\n---\n"), 3);
506        assert_eq!(
507            frontmatter_line_count("---\nnever closed\n"),
508            0,
509            "unterminated fence is not frontmatter"
510        );
511        assert!(frontmatter_has_title("---\nTitle: \"X\"\n---\n", 3));
512        assert!(!frontmatter_has_title("---\nsubtitle: \"X\"\n---\n", 3));
513        assert!(!frontmatter_has_title("body only\n", 0));
514    }
515
516    #[test]
517    fn unreadable_markdown_file_is_skipped() {
518        // Invalid UTF-8 makes read_to_string fail, driving the
519        // per-file `continue` branch without permission games.
520        let s = site_with_content(&[("good.md", "# ok\n")]);
521        let content = s.root.parent().unwrap().join("content");
522        std::fs::write(content.join("binary.md"), [0xFF, 0xFE, 0x00, 0x9F])
523            .unwrap();
524        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
525        assert!(f.is_empty(), "unreadable file must be skipped: {f:?}");
526    }
527
528    #[test]
529    fn content_dir_directly_under_root_is_discovered() {
530        let tmp = tempfile::tempdir().unwrap();
531        let root = tmp.path().join("site");
532        let content = root.join("content");
533        std::fs::create_dir_all(&content).unwrap();
534        std::fs::write(content.join("doc.md"), "no heading here\n").unwrap();
535        std::mem::forget(tmp);
536        let s = Site {
537            root,
538            html_files: Vec::new(),
539        };
540        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
541        assert!(
542            f.iter().any(|x| x.code.as_deref() == Some("MD041")),
543            "direct <root>/content must be scanned: {f:?}"
544        );
545    }
546
547    #[test]
548    fn root_without_parent_skips_with_info() {
549        // `/` has no parent, driving the `parent()?` early return.
550        let s = Site {
551            root: PathBuf::from("/"),
552            html_files: Vec::new(),
553        };
554        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
555        if !Path::new("/content").is_dir() {
556            assert_eq!(f.len(), 1);
557            assert_eq!(f[0].code.as_deref(), Some("MD-INPUT-MISSING"));
558        }
559    }
560
561    #[test]
562    fn autolink_url_in_angle_brackets_is_silent() {
563        let s = site_with_content(&[(
564            "doc.md",
565            "# title\n\nVisit <https://example.com> today.\n",
566        )]);
567        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
568        assert!(
569            f.iter().all(|x| x.code.as_deref() != Some("MD034")),
570            "autolink form must not trip MD034: {f:?}"
571        );
572    }
573
574    #[test]
575    fn leading_blank_line_then_heading_is_clean() {
576        // First physical line is blank: first_heading_candidate must
577        // fall through to the first non-blank line.
578        let s = site_with_content(&[("doc.md", "\n# Title\n\nbody\n")]);
579        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
580        assert!(
581            f.iter().all(|x| x.code.as_deref() != Some("MD041")),
582            "blank first line must not trip MD041: {f:?}"
583        );
584    }
585
586    #[test]
587    fn whitespace_only_line_is_exempt_from_md009() {
588        // A line made up entirely of spaces still `ends_with(' ')`, but
589        // `raw_line.trim().is_empty()` is also true, so MD009 must not
590        // fire for it — only genuine trailing whitespace after real
591        // content counts. The sibling trailing-ws line keeps `f`
592        // non-empty so the exemption predicate actually evaluates.
593        let s = site_with_content(&[(
594            "doc.md",
595            "# title\n\n   \nreal trailing ws \n",
596        )]);
597        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
598        let md009_count = f
599            .iter()
600            .filter(|x| x.code.as_deref() == Some("MD009"))
601            .count();
602        assert_eq!(
603            md009_count, 1,
604            "only the real trailing-ws line should trip MD009: {f:?}"
605        );
606    }
607
608    #[test]
609    fn bare_http_url_without_s_is_flagged() {
610        // MD034 also fires for plain `http://` (not just `https://`).
611        let s = site_with_content(&[(
612            "doc.md",
613            "# title\n\nSee http://bare.example\n",
614        )]);
615        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
616        assert!(
617            f.iter().any(|x| x.code.as_deref() == Some("MD034")),
618            "bare http:// (no s) must still trip MD034: {f:?}"
619        );
620    }
621
622    #[test]
623    fn markdown_link_with_space_before_closing_paren_is_silent() {
624        // `[text](url )` — a space before the closing paren means the
625        // whitespace-delimited URL token does not itself end in `)`,
626        // so `in_bracket` is false even though `in_link` (before ends
627        // with `](`) is true; the finding must still be suppressed by
628        // `in_link` alone.
629        let s = site_with_content(&[(
630            "doc.md",
631            "# title\n\n[text](https://example.com )\n",
632        )]);
633        let f = MarkdownlintGate.run(&s, &AuditOptions::default());
634        assert!(
635            f.iter().all(|x| x.code.as_deref() != Some("MD034")),
636            "space-before-paren markdown link must still be silent: {f:?}"
637        );
638    }
639
640    #[test]
641    fn metadata_methods_exposed() {
642        let g = MarkdownlintGate;
643        assert_eq!(g.name(), "markdownlint");
644        assert!(g.explain().contains("MD0"));
645        let _copy: MarkdownlintGate = g;
646        let _clone = g;
647        assert!(format!("{g:?}").contains("MarkdownlintGate"));
648    }
649}