use anyhow::{Context, Result};
use gray_matter::engine::YAML;
use gray_matter::Matter;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditReport {
pub file_path: String,
pub title: Option<String>,
pub title_len: usize,
pub description: Option<String>,
pub description_len: usize,
pub h1_count: usize,
pub h2_count: usize,
pub h3_count: usize,
pub word_count: usize,
pub image_count: usize,
pub images_missing_alt: usize,
pub internal_links: usize,
pub external_links: usize,
pub schema_found: bool,
pub canonical_found: bool,
pub og_tags_found: bool,
pub geo_opening_words: usize,
pub heading_skipped_levels: Vec<String>,
pub em_dash_count: usize,
pub ai_slop_words_found: Vec<String>,
pub internal_link_targets: Vec<String>,
pub checks: Vec<CheckItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckItem {
pub name: String,
pub passed: bool,
pub message: String,
}
pub const MIN_TITLE_CHARS: usize = 30;
pub const MAX_TITLE_CHARS: usize = 65;
pub const MIN_DESC_CHARS: usize = 80;
pub const MAX_DESC_CHARS: usize = 165;
pub const MIN_CONTENT_WORDS: usize = 300;
pub const GEO_MIN_WORDS: usize = 100;
pub const GEO_MAX_WORDS: usize = 200;
pub const AI_SLOP_PHRASES: &[&str] = &[
"delve",
"leverage",
"testament",
"bolster",
"foster",
"seamless",
"seamlessly",
"crucial",
"robust",
"landscape",
"furthermore",
"moreover",
"underscores",
"unveil",
"tapestry",
"beacon",
"in summary",
"in today's",
];
pub fn check_heading_hierarchy(skipped: &[String]) -> CheckItem {
let passed = skipped.is_empty();
CheckItem {
name: "Heading Hierarchy".into(),
passed,
message: if passed {
"Logical heading progression (no skipped levels)".into()
} else {
format!("Skipped levels detected: [{}]", skipped.join(", "))
},
}
}
pub fn check_ai_slop(em_dash_count: usize, slop_words: &[String], word_count: usize) -> CheckItem {
let em_dash_density = if word_count > 0 {
(em_dash_count as f64 / word_count as f64) * 500.0
} else {
0.0
};
let is_excessive_dashes = em_dash_density > 2.0 && em_dash_count >= 2;
let is_excessive_words = slop_words.len() >= 3;
let passed = !is_excessive_dashes && !is_excessive_words;
let message = if passed {
format!("Natural writing tone ({} em-dashes, {} AI crutches)", em_dash_count, slop_words.len())
} else {
format!(
"Helpful Content risk: {} em-dashes ({:.1}/500w) and {} AI tells [{}]",
em_dash_count,
em_dash_density,
slop_words.len(),
slop_words.join(", ")
)
};
CheckItem {
name: "Helpful Content (AI Slop)".into(),
passed,
message,
}
}
pub fn check_title_length(title_len: usize) -> CheckItem {
let passed = (MIN_TITLE_CHARS..=MAX_TITLE_CHARS).contains(&title_len);
CheckItem {
name: "Title Tag Length".into(),
passed,
message: format!("Length: {} chars (Optimal: {}-{} chars)", title_len, MIN_TITLE_CHARS, MAX_TITLE_CHARS),
}
}
pub fn check_meta_description(description_len: usize) -> CheckItem {
let passed = (MIN_DESC_CHARS..=MAX_DESC_CHARS).contains(&description_len);
CheckItem {
name: "Meta Description".into(),
passed,
message: if description_len == 0 {
"Missing description metadata".into()
} else {
format!("Length: {} chars (Optimal: {}-{} chars)", description_len, MIN_DESC_CHARS, MAX_DESC_CHARS)
},
}
}
pub fn check_h1_uniqueness(h1_count: usize) -> CheckItem {
CheckItem {
name: "H1 Uniqueness".into(),
passed: h1_count == 1,
message: format!("Found {} H1 headings (Expected: exactly 1)", h1_count),
}
}
pub fn check_content_depth(word_count: usize) -> CheckItem {
CheckItem {
name: "Content Depth".into(),
passed: word_count >= MIN_CONTENT_WORDS,
message: format!("Word count: {} (Recommended min: {} words)", word_count, MIN_CONTENT_WORDS),
}
}
pub fn check_image_alt_tags(image_count: usize, images_missing_alt: usize) -> CheckItem {
CheckItem {
name: "Image Alt Tags".into(),
passed: images_missing_alt == 0,
message: format!("Images: {}, Missing Alt: {}", image_count, images_missing_alt),
}
}
pub fn check_geo_citation_density(words: usize) -> CheckItem {
let passed = (GEO_MIN_WORDS..=GEO_MAX_WORDS).contains(&words);
CheckItem {
name: "GEO Citation Density".into(),
passed,
message: format!("Opening passage: {} words (Optimal AI citation block: 134-167 words)", words),
}
}
pub fn check_schema_markup(schema_found: bool) -> CheckItem {
CheckItem {
name: "Schema Markup".into(),
passed: schema_found,
message: if schema_found { "Structured data present".into() } else { "No JSON-LD/schema markup defined".into() },
}
}
pub fn check_canonical_reference(canonical_found: bool) -> CheckItem {
CheckItem {
name: "Canonical Reference".into(),
passed: canonical_found,
message: if canonical_found { "Canonical tag configured".into() } else { "Missing canonical URL definition".into() },
}
}
pub fn check_opengraph_metadata(og_found: bool) -> CheckItem {
CheckItem {
name: "OpenGraph Metadata".into(),
passed: og_found,
message: if og_found { "OpenGraph meta tags found".into() } else { "Missing og:title, og:description, or og:image tags".into() },
}
}
pub fn audit_file(path_str: &str) -> Result<AuditReport> {
let path = Path::new(path_str);
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read file: {}", path_str))?;
let is_markdown = path.extension().is_some_and(|ext| {
ext == "md" || ext == "mdx" || ext == "markdown"
});
if is_markdown {
audit_markdown(path_str, &content)
} else {
audit_html(path_str, &content)
}
}
fn audit_markdown(path_str: &str, content: &str) -> Result<AuditReport> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let mut title: Option<String> = None;
let mut description: Option<String> = None;
let mut canonical_found = false;
let mut schema_found = false;
let mut og_tags_found = false;
if let Some(data) = parsed.data {
if let Ok(val) = data.as_hashmap() {
if let Some(t) = val.get("title") {
title = t.as_string().ok();
}
if let Some(d) = val.get("description") {
description = d.as_string().ok();
}
if val.contains_key("canonical") || val.contains_key("canonical_url") {
canonical_found = true;
}
if val.contains_key("schema") || val.contains_key("json_ld") {
schema_found = true;
}
if val.contains_key("og_image") || val.contains_key("image") {
og_tags_found = true;
}
}
}
let body = parsed.content;
let mut h1_count = 0;
let mut h2_count = 0;
let mut h3_count = 0;
let mut image_count = 0;
let mut images_missing_alt = 0;
let mut internal_links = 0;
let mut external_links = 0;
let mut internal_link_targets = Vec::new();
let mut prev_heading_level: Option<usize> = None;
let mut heading_skipped_levels = Vec::new();
let mut first_section_words = 0;
let mut past_first_heading = false;
let link_re = Regex::new(r#"\[([^\]]*)\]\(([^)]+)\)"#)?;
for line in body.lines() {
let trimmed = line.trim();
let heading_lvl = if let Some(stripped) = trimmed.strip_prefix("# ") {
h1_count += 1;
past_first_heading = true;
if title.is_none() {
title = Some(stripped.trim().to_string());
}
Some(1)
} else if trimmed.starts_with("## ") {
h2_count += 1;
past_first_heading = true;
Some(2)
} else if trimmed.starts_with("### ") {
h3_count += 1;
Some(3)
} else if trimmed.starts_with("#### ") {
Some(4)
} else if trimmed.starts_with("##### ") {
Some(5)
} else if trimmed.starts_with("###### ") {
Some(6)
} else {
None
};
if let Some(lvl) = heading_lvl {
if let Some(prev) = prev_heading_level {
if lvl > prev + 1 {
heading_skipped_levels.push(format!("H{} -> H{}", prev, lvl));
}
}
prev_heading_level = Some(lvl);
} else if !past_first_heading || h2_count == 0 {
first_section_words += trimmed.split_whitespace().count();
}
if trimmed.contains(" {
images_missing_alt += 1;
}
}
for cap in link_re.captures_iter(trimmed) {
let target = cap[2].trim().split('#').next().unwrap_or("").trim();
if target.starts_with("http://") || target.starts_with("https://") {
external_links += 1;
} else if !target.is_empty() && !target.starts_with('#') && !target.starts_with("mailto:") {
internal_links += 1;
internal_link_targets.push(target.to_string());
}
}
}
let words: Vec<&str> = body.split_whitespace().collect();
let word_count = words.len();
let title_len = title.as_ref().map(|s| s.chars().count()).unwrap_or(0);
let description_len = description.as_ref().map(|s| s.chars().count()).unwrap_or(0);
let em_dash_count = content.chars().filter(|&c| c == '\u{2014}').count();
let lower_body = body.to_lowercase();
let mut ai_slop_words_found = Vec::new();
for &phrase in AI_SLOP_PHRASES {
if lower_body.contains(phrase) {
ai_slop_words_found.push(phrase.to_string());
}
}
let checks = vec![
check_title_length(title_len),
check_meta_description(description_len),
check_h1_uniqueness(h1_count),
check_heading_hierarchy(&heading_skipped_levels),
check_content_depth(word_count),
check_image_alt_tags(image_count, images_missing_alt),
check_geo_citation_density(first_section_words),
check_ai_slop(em_dash_count, &ai_slop_words_found, word_count),
check_schema_markup(schema_found),
check_canonical_reference(canonical_found),
];
Ok(AuditReport {
file_path: path_str.to_string(),
title,
title_len,
description,
description_len,
h1_count,
h2_count,
h3_count,
word_count,
image_count,
images_missing_alt,
internal_links,
external_links,
schema_found,
canonical_found,
og_tags_found,
geo_opening_words: first_section_words,
heading_skipped_levels,
em_dash_count,
ai_slop_words_found,
internal_link_targets,
checks,
})
}
fn audit_html(path_str: &str, content: &str) -> Result<AuditReport> {
let title_re = Regex::new(r#"(?is)<title[^>]*>(.*?)</title>"#)?;
let meta_desc_re = Regex::new(r#"(?is)<meta[^>]*name=["']description["'][^>]*content=["'](.*?)["']"#)?;
let img_re = Regex::new(r#"(?is)<img\b([^>]*)>"#)?;
let alt_re = Regex::new(r#"(?is)alt=["']([^"']+)["']"#)?;
let a_re = Regex::new(r#"(?is)<a\b[^>]*href=["']([^"']*)["']"#)?;
let schema_re = Regex::new(r#"(?is)<script[^>]*type=["']application/ld\+json["'][^>]*>.*?</script>"#)?;
let canonical_re = Regex::new(r#"(?is)<link[^>]*rel=["']canonical["'][^>]*href=["'](.*?)["']"#)?;
let og_re = Regex::new(r#"(?is)<meta[^>]*property=["']og:(title|description|image)["']"#)?;
let strip_html = Regex::new(r#"<[^>]+>"#)?;
let title = title_re.captures(content).map(|c| c[1].trim().to_string());
let description = meta_desc_re.captures(content).map(|c| c[1].trim().to_string());
let h1_count = Regex::new(r#"(?is)<h1\b[^>]*>.*?</h1>"#)?.find_iter(content).count();
let h2_count = Regex::new(r#"(?is)<h2\b[^>]*>.*?</h2>"#)?.find_iter(content).count();
let h3_count = Regex::new(r#"(?is)<h3\b[^>]*>.*?</h3>"#)?.find_iter(content).count();
let heading_seq_re = Regex::new(r#"(?is)<(h[1-6])\b[^>]*>"#)?;
let mut prev_heading_level: Option<usize> = None;
let mut heading_skipped_levels = Vec::new();
for cap in heading_seq_re.captures_iter(content) {
let tag = &cap[1];
if let Some(lvl_char) = tag.chars().nth(1) {
if let Some(lvl) = lvl_char.to_digit(10).map(|d| d as usize) {
if let Some(prev) = prev_heading_level {
if lvl > prev + 1 {
heading_skipped_levels.push(format!("H{} -> H{}", prev, lvl));
}
}
prev_heading_level = Some(lvl);
}
}
}
let schema_found = schema_re.is_match(content);
let canonical_found = canonical_re.is_match(content);
let og_tags_found = og_re.is_match(content);
let mut image_count = 0;
let mut images_missing_alt = 0;
for cap in img_re.captures_iter(content) {
image_count += 1;
let attrs = &cap[1];
if !alt_re.is_match(attrs) {
images_missing_alt += 1;
}
}
let mut internal_links = 0;
let mut external_links = 0;
let mut internal_link_targets = Vec::new();
for cap in a_re.captures_iter(content) {
let href = cap[1].trim();
let target = href.split('#').next().unwrap_or("").trim();
if target.starts_with("http://") || target.starts_with("https://") {
external_links += 1;
} else if !target.is_empty() && !target.starts_with('#') && !target.starts_with("mailto:") {
internal_links += 1;
internal_link_targets.push(target.to_string());
}
}
let plain_text = strip_html.replace_all(content, " ");
let words: Vec<&str> = plain_text.split_whitespace().collect();
let word_count = words.len();
let first_30_pct_words = (word_count as f64 * 0.30).round() as usize;
let title_len = title.as_ref().map(|s| s.chars().count()).unwrap_or(0);
let description_len = description.as_ref().map(|s| s.chars().count()).unwrap_or(0);
let em_dash_count = content.chars().filter(|&c| c == '\u{2014}').count();
let lower_content = plain_text.to_lowercase();
let mut ai_slop_words_found = Vec::new();
for &phrase in AI_SLOP_PHRASES {
if lower_content.contains(phrase) {
ai_slop_words_found.push(phrase.to_string());
}
}
let checks = vec![
check_title_length(title_len),
check_meta_description(description_len),
check_h1_uniqueness(h1_count),
check_heading_hierarchy(&heading_skipped_levels),
check_content_depth(word_count),
check_image_alt_tags(image_count, images_missing_alt),
check_geo_citation_density(first_30_pct_words),
check_ai_slop(em_dash_count, &ai_slop_words_found, word_count),
check_schema_markup(schema_found),
check_canonical_reference(canonical_found),
check_opengraph_metadata(og_tags_found),
];
Ok(AuditReport {
file_path: path_str.to_string(),
title,
title_len,
description,
description_len,
h1_count,
h2_count,
h3_count,
word_count,
image_count,
images_missing_alt,
internal_links,
external_links,
schema_found,
canonical_found,
og_tags_found,
geo_opening_words: first_30_pct_words,
heading_skipped_levels,
em_dash_count,
ai_slop_words_found,
internal_link_targets,
checks,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CannibalizationItem {
pub keyword_stem: String,
pub colliding_files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryAuditReport {
pub dir_path: String,
pub total_files: usize,
pub total_words: usize,
pub avg_words_per_file: usize,
pub pass_rate: f64,
pub reports: Vec<AuditReport>,
pub duplicate_titles: std::collections::HashMap<String, Vec<String>>,
pub thin_pages: Vec<(String, usize)>,
pub missing_canonicals: Vec<String>,
pub missing_descriptions: Vec<String>,
pub orphan_pages: Vec<String>,
pub keyword_cannibalization: Vec<CannibalizationItem>,
#[serde(default)]
pub findings: Vec<crate::rules::Finding>,
}
pub fn audit_path(path_str: &str) -> Result<DirectoryAuditReport> {
let path = Path::new(path_str);
if !path.exists() {
anyhow::bail!("Path does not exist: {}", path_str);
}
if path.is_file() {
let single = audit_file(path_str)?;
let total_words = single.word_count;
let mut missing_canonicals = Vec::new();
if !single.canonical_found {
missing_canonicals.push(path_str.to_string());
}
let mut missing_descriptions = Vec::new();
if single.description.is_none() {
missing_descriptions.push(path_str.to_string());
}
let mut thin_pages = Vec::new();
if single.word_count < MIN_CONTENT_WORDS {
thin_pages.push((path_str.to_string(), single.word_count));
}
let passed = single.checks.iter().filter(|c| c.passed).count();
let pass_rate = if !single.checks.is_empty() {
(passed as f64 / single.checks.len() as f64) * 100.0
} else {
100.0
};
return Ok(with_findings(DirectoryAuditReport {
dir_path: path_str.to_string(),
total_files: 1,
total_words,
avg_words_per_file: total_words,
pass_rate,
reports: vec![single],
duplicate_titles: std::collections::HashMap::new(),
thin_pages,
missing_canonicals,
missing_descriptions,
orphan_pages: Vec::new(),
keyword_cannibalization: Vec::new(),
findings: Vec::new(),
}))
}
let mut files = Vec::new();
collect_audit_files(path, &mut files)?;
files.sort();
if files.is_empty() {
anyhow::bail!("No markdown (.md/.mdx) or HTML files found in directory: {}", path_str);
}
let mut reports = Vec::new();
let mut total_words = 0;
let mut total_checks = 0;
let mut total_passed = 0;
let mut title_map: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
let mut thin_pages = Vec::new();
let mut missing_canonicals = Vec::new();
let mut missing_descriptions = Vec::new();
for f in &files {
let p_str = f.to_string_lossy().to_string();
if let Ok(rep) = audit_file(&p_str) {
total_words += rep.word_count;
for c in &rep.checks {
total_checks += 1;
if c.passed {
total_passed += 1;
}
}
if let Some(t) = &rep.title {
let clean_t = t.trim().to_string();
if !clean_t.is_empty() {
title_map.entry(clean_t).or_default().push(p_str.clone());
}
}
if rep.word_count < MIN_CONTENT_WORDS {
thin_pages.push((p_str.clone(), rep.word_count));
}
if !rep.canonical_found {
missing_canonicals.push(p_str.clone());
}
if rep.description.is_none() {
missing_descriptions.push(p_str.clone());
}
reports.push(rep);
}
}
let duplicate_titles: std::collections::HashMap<String, Vec<String>> = title_map
.into_iter()
.filter(|(_, paths)| paths.len() > 1)
.collect();
// Internal Link Graph & Orphan Page Detection
let mut inbound_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for rep in &reports {
let rep_norm = rep.file_path.replace('\\', "/");
let rep_stem = Path::new(&rep.file_path).file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();
for raw_target in &rep.internal_link_targets {
let clean = raw_target.trim().trim_start_matches("./").replace('\\', "/");
let clean_stem = clean.split('/').next_back().unwrap_or("").to_string();
for f in &files {
let f_norm = f.to_string_lossy().replace('\\', "/");
let f_name = f.file_name().and_then(|n| n.to_str()).unwrap_or("");
let f_stem = f.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if (f_norm.ends_with(&clean) || f_name == clean_stem || f_stem == clean_stem)
&& f_norm != rep_norm
&& f_name != rep_stem
{
*inbound_map.entry(f_norm.clone()).or_insert(0) += 1;
}
}
}
}
let mut orphan_pages = Vec::new();
if files.len() > 1 {
for f in &files {
let f_norm = f.to_string_lossy().replace('\\', "/");
let f_name = f.file_name().and_then(|n| n.to_str()).unwrap_or("").to_lowercase();
// Skip root index / readme as landing entrypoint
if f_name.starts_with("readme") || f_name.starts_with("index") {
continue;
}
if inbound_map.get(&f_norm).copied().unwrap_or(0) == 0 {
orphan_pages.push(f.to_string_lossy().to_string());
}
}
}
// Keyword Cannibalization Radar
let stop_words: std::collections::HashSet<&str> = [
"a", "an", "the", "in", "on", "at", "for", "to", "of", "and", "or", "with", "by", "is", "vs", "how"
].into_iter().collect();
let mut stem_to_files: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
for rep in &reports {
if let Some(title) = &rep.title {
let words: Vec<String> = title
.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty() && !stop_words.contains(w) && w.len() > 2)
.map(|s| s.to_string())
.collect();
if words.len() >= 2 {
let stem = words[..words.len().min(3)].join(" ");
stem_to_files.entry(stem).or_default().push(rep.file_path.clone());
}
}
}
let keyword_cannibalization: Vec<CannibalizationItem> = stem_to_files
.into_iter()
.filter(|(_, paths)| paths.len() > 1)
.map(|(keyword_stem, colliding_files)| CannibalizationItem { keyword_stem, colliding_files })
.collect();
let total_files = reports.len();
let avg_words_per_file = total_words.checked_div(total_files).unwrap_or(0);
let pass_rate = if total_checks > 0 {
(total_passed as f64 / total_checks as f64) * 100.0
} else {
100.0
};
Ok(DirectoryAuditReport {
dir_path: path_str.to_string(),
total_files,
total_words,
avg_words_per_file,
pass_rate,
reports,
duplicate_titles,
thin_pages,
missing_canonicals,
missing_descriptions,
orphan_pages,
keyword_cannibalization,
findings: Vec::new(),
})
}
/// Fill rule findings after construction. Shared by live audits and --rescore.
pub fn with_findings(mut rep: DirectoryAuditReport) -> DirectoryAuditReport {
rep.findings = crate::rules::check_audit(&rep);
rep
}
fn collect_audit_files(dir: &Path, files: &mut Vec<std::path::PathBuf>) -> Result<()> {
if dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name.starts_with('.') || file_name == "node_modules" || file_name == "target" || file_name == "dist" || file_name == "build" {
continue;
}
if path.is_dir() {
collect_audit_files(&path, files)?;
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext == "md" || ext == "mdx" || ext == "markdown" || ext == "html" || ext == "htm" {
files.push(path);
}
}
}
}
Ok(())
}
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
/// ASCII-fold a line for PDF standard fonts (WinAnsi only).
fn pdf_text(s: &str) -> String {
s.chars()
.map(|c| match c {
'—' | '–' => '-',
'“' | '”' => '"',
'‘' | '’' => '\'',
'…' => '.',
'▲' => '^',
'▼' => 'v',
'✖' | '⚠' => '!',
c if c.is_ascii() => c,
_ => '?',
})
.collect::<String>()
.replace('\\', "\\\\")
.replace('(', "\\(")
.replace(')', "\\)")
}
/// Minimal multi-page PDF 1.4 writer, std only. Helvetica, A4, 11pt.
fn pdf_lines(title: &str, lines: &[String]) -> Vec<u8> {
const PER_PAGE: usize = 48;
let mut pages: Vec<&[String]> = Vec::new();
let mut i = 0;
while i < lines.len().max(1) {
pages.push(&lines[i..(i + PER_PAGE).min(lines.len())]);
i += PER_PAGE;
if lines.is_empty() {
break;
}
}
// Objects: 1 catalog, 2 pages, 3 font, then per page (page, content).
let mut objs: Vec<Vec<u8>> = Vec::new();
objs.push(b"<< /Type /Catalog /Pages 2 0 R >>".to_vec());
let kids: Vec<String> = (0..pages.len()).map(|p| format!("{} 0 R", 4 + p * 2)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), pages.len()).as_bytes().to_vec());
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
for (pi, chunk) in pages.iter().enumerate() {
let content_obj = 5 + pi * 2;
objs.push(
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents {} 0 R >>",
content_obj
)
.as_bytes()
.to_vec(),
);
let mut stream = String::from("BT /F1 11 Tf 50 790 Td 14 TL ");
if pi == 0 {
stream.push_str(&format!("({}) Tj T* ", pdf_text(title)));
}
for line in chunk.iter() {
stream.push_str(&format!("({}) Tj T* ", pdf_text(line)));
}
stream.push_str("ET");
let bytes = stream.as_bytes();
let mut obj = format!("<< /Length {} >>\nstream\n", bytes.len()).as_bytes().to_vec();
obj.extend_from_slice(bytes);
obj.extend_from_slice(b"\nendstream");
objs.push(obj);
}
let mut out = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (n, body) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", n + 1).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref_at = out.len();
out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
out.extend_from_slice(b"0000000000 65535 f \n");
for off in &offsets {
out.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes());
}
out.extend_from_slice(
format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF", objs.len() + 1, xref_at).as_bytes(),
);
out
}
/// Markdown twin of to_html: scorecard, pages, findings, method.
pub fn to_markdown(rep: &DirectoryAuditReport) -> String {
let score = rep.pass_rate.round().clamp(0.0, 100.0) as u32;
let mut m = String::new();
m.push_str(&format!(
"# SEO audit: {}\n\nScore **{}/100 ({})** across {} files and {} words, pass rate {:.1}%.\n",
rep.dir_path, score, crate::actions::grade(score), rep.total_files, rep.total_words, rep.pass_rate
));
m.push_str("\n## Pages\n\n| Page | Words | Title | Checks |\n|---|---|---|---|\n");
for r in &rep.reports {
let passed = r.checks.iter().filter(|c| c.passed).count();
m.push_str(&format!(
"| {} | {} | {} | {}/{} |\n",
r.file_path,
r.word_count,
r.title.as_deref().unwrap_or(""),
passed,
r.checks.len()
));
}
if !rep.duplicate_titles.is_empty() {
m.push_str(&format!("\n## Duplicate titles ({})\n\n", rep.duplicate_titles.len()));
let mut titles: Vec<&String> = rep.duplicate_titles.keys().collect();
titles.sort();
for t in titles {
m.push_str(&format!("- {} ({} files)\n", t, rep.duplicate_titles[t].len()));
}
}
if !rep.orphan_pages.is_empty() {
m.push_str(&format!("\n## Orphan pages ({})\n\n", rep.orphan_pages.len()));
for f in &rep.orphan_pages {
m.push_str(&format!("- {}\n", f));
}
}
if !rep.thin_pages.is_empty() {
m.push_str(&format!("\n## Thin pages ({})\n\n", rep.thin_pages.len()));
for (f, wc) in &rep.thin_pages {
m.push_str(&format!("- {} ({} words)\n", f, wc));
}
}
if !rep.keyword_cannibalization.is_empty() {
m.push_str(&format!("\n## Keyword cannibalization ({})\n\n", rep.keyword_cannibalization.len()));
for item in &rep.keyword_cannibalization {
m.push_str(&format!("- {} ({} pages)\n", item.keyword_stem, item.colliding_files.len()));
}
}
m.push_str("\n## Method\n\nOn-page checks per file, duplicate titles, orphan link graph, thin-page and cannibalization radar. Scores rank work; they never predict rankings or traffic.\n");
m
}
/// PDF twin of to_html: scorecard, pages, findings, method.
pub fn to_pdf(rep: &DirectoryAuditReport) -> Vec<u8> {
let score = rep.pass_rate.round().clamp(0.0, 100.0) as u32;
let mut lines = vec![
format!("SEO audit: {} | score {}/100 ({})", rep.dir_path, score, crate::actions::grade(score)),
format!("Files: {} Words: {} Pass rate: {:.1}%", rep.total_files, rep.total_words, rep.pass_rate),
String::new(),
"Pages (path | words | title | checks):".to_string(),
];
for r in &rep.reports {
let passed = r.checks.iter().filter(|c| c.passed).count();
lines.push(format!(
"- {} | {}w | {} | {}/{}",
r.file_path,
r.word_count,
r.title.as_deref().unwrap_or(""),
passed,
r.checks.len()
));
}
if !rep.duplicate_titles.is_empty() {
lines.push(String::new());
lines.push(format!("Duplicate titles ({}):", rep.duplicate_titles.len()));
let mut titles: Vec<&String> = rep.duplicate_titles.keys().collect();
titles.sort();
for t in titles.iter().take(10) {
lines.push(format!("- {} ({} files)", t, rep.duplicate_titles[*t].len()));
}
}
if !rep.orphan_pages.is_empty() {
lines.push(String::new());
lines.push(format!("Orphan pages ({}):", rep.orphan_pages.len()));
for f in rep.orphan_pages.iter().take(10) {
lines.push(format!("- {}", f));
}
}
if !rep.thin_pages.is_empty() {
lines.push(String::new());
lines.push(format!("Thin pages ({}):", rep.thin_pages.len()));
for (f, wc) in rep.thin_pages.iter().take(10) {
lines.push(format!("- {} ({} words)", f, wc));
}
}
lines.push(String::new());
lines.push("Method: on-page checks per file, duplicate titles, orphan link graph, thin-page and cannibalization radar.".to_string());
lines.push("Scores rank work; they never predict rankings or traffic.".to_string());
pdf_lines(&format!("jev-seo audit report: {}", rep.dir_path), &lines)
}
/// Single-file HTML report for humans. Same numbers as the JSON output.
pub fn to_html(rep: &DirectoryAuditReport) -> String {
let score = rep.pass_rate.round().clamp(0.0, 100.0) as u32;
let grade = crate::actions::grade(score);
let mut h = String::new();
h.push_str("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">");
h.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
h.push_str("<title>jev-seo audit report</title>");
h.push_str("<style>:root{--pink:#f386a1;--ink:#111;--mut:#666;--line:#e2e2e2}body{font-family:system-ui,-apple-system,sans-serif;color:var(--ink);max-width:960px;margin:0 auto;padding:2rem 1rem;line-height:1.55}.hero{background:#111;color:#fff;border-radius:10px;padding:2rem;display:flex;gap:2rem;align-items:center;flex-wrap:wrap}.grade{font-size:4rem;font-weight:800;background:var(--pink);color:#111;border-radius:10px;min-width:6rem;text-align:center}.meta{font-size:.9rem;color:#bbb}table{border-collapse:collapse;width:100%;margin-top:1rem}th,td{border:1px solid var(--line);padding:.45rem .65rem;text-align:left;font-size:.9rem}th{background:#f6f6f6}h2{margin-top:2.2rem;font-size:1.15rem;border-bottom:2px solid var(--pink);display:inline-block}ul{padding-left:1.2rem}.foot{margin-top:2.5rem;font-size:.8rem;color:var(--mut);border-top:1px solid var(--line);padding-top:1rem}code{background:#f1f1f1;padding:.1rem .35rem;border-radius:4px;font-size:.85em}</style>");
h.push_str("</head><body>");
h.push_str(&format!(
"<div class=\"hero\"><div class=\"grade\">{}</div><div><h1 style=\"margin:0;font-size:1.5rem\">SEO audit: {}</h1><p class=\"meta\">{} files | {} words | pass rate {:.1}% | score {}/100</p></div></div>",
grade,
esc(&rep.dir_path),
rep.total_files,
rep.total_words,
rep.pass_rate,
score
));
h.push_str("<h2>Pages</h2><table><tr><th>Page</th><th>Words</th><th>Title</th><th>Checks passed</th></tr>");
for r in &rep.reports {
let passed = r.checks.iter().filter(|c| c.passed).count();
h.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}/{}</td></tr>",
esc(&r.file_path),
r.word_count,
esc(r.title.as_deref().unwrap_or("")),
passed,
r.checks.len()
));
}
h.push_str("</table>");
if !rep.duplicate_titles.is_empty() {
h.push_str("<h2>Duplicate titles</h2><ul>");
let mut titles: Vec<&String> = rep.duplicate_titles.keys().collect();
titles.sort();
for t in titles {
h.push_str(&format!("<li>{} ({} files)</li>", esc(t), rep.duplicate_titles[t].len()));
}
h.push_str("</ul>");
}
if !rep.orphan_pages.is_empty() {
h.push_str(&format!("<h2>Orphan pages ({})</h2><ul>", rep.orphan_pages.len()));
for f in &rep.orphan_pages {
h.push_str(&format!("<li>{}</li>", esc(f)));
}
h.push_str("</ul>");
}
if !rep.thin_pages.is_empty() {
h.push_str(&format!("<h2>Thin pages ({})</h2><ul>", rep.thin_pages.len()));
for (f, wc) in &rep.thin_pages {
h.push_str(&format!("<li>{} ({} words)</li>", esc(f), wc));
}
h.push_str("</ul>");
}
if !rep.keyword_cannibalization.is_empty() {
h.push_str("<h2>Keyword cannibalization</h2><ul>");
for item in &rep.keyword_cannibalization {
h.push_str(&format!(
"<li>{} ({} pages)</li>",
esc(&item.keyword_stem),
item.colliding_files.len()
));
}
h.push_str("</ul>");
}
h.push_str("<div class=\"foot\">Generated locally by <code>jev-seo audit --html</code>. Scores rank work; they never predict rankings or traffic. Method: on-page checks per file, duplicate titles, orphan detection via internal link graph, thin-page and cannibalization radar.</div>");
h.push_str("</body></html>");
h
}