1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
26use crate::walk::walk_files;
27use std::path::Path;
28
29const NAME: &str = "markdownlint";
30
31#[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 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 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 if let Some(idx2) = raw_line
160 .find("http://")
161 .or_else(|| raw_line.find("https://"))
162 {
163 let before = &raw_line[..idx2];
164 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
205fn 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
223fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}