oratos 0.3.1

Website visibility intelligence for SEO, accessibility, structured metadata, and AI readiness.
Documentation
use crate::html::{is_site_root_path, normalize_path_separators, HtmlPage};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataRecommendation {
    pub page: String,
    pub title: Option<String>,
    pub meta_description: Option<String>,
    pub canonical_url: Option<String>,
    pub open_graph: OpenGraphRecommendation,
    pub twitter_card: TwitterRecommendation,
    pub json_ld: Vec<serde_json::Value>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenGraphRecommendation {
    pub title: Option<String>,
    pub description: Option<String>,
    pub image: Option<String>,
    pub url: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TwitterRecommendation {
    pub card: Option<String>,
    pub title: Option<String>,
    pub description: Option<String>,
    pub image: Option<String>,
}

pub fn generate_metadata_recommendations(pages: &[HtmlPage]) -> Vec<MetadataRecommendation> {
    pages.iter().map(|p| recommend_for_page(p, pages)).collect()
}

fn recommend_for_page(page: &HtmlPage, all_pages: &[HtmlPage]) -> MetadataRecommendation {
    let title = page.title.clone().or_else(|| {
        page.headings
            .iter()
            .find(|h| h.level == 1)
            .map(|h| h.text.clone())
    });

    let meta_description = page
        .meta_description
        .clone()
        .or_else(|| Some(truncate(&page.main_text, 160)));

    let canonical_url = page
        .canonical_urls
        .first()
        .cloned()
        .or_else(|| Some(page.url_or_path.clone()));

    let og_title = page.open_graph.title.clone().or_else(|| title.clone());
    let og_description = page
        .open_graph
        .description
        .clone()
        .or_else(|| meta_description.clone());
    let og_image = page.open_graph.image.clone().or_else(|| {
        page.images
            .first()
            .map(|i| i.src.clone())
            .filter(|s| !s.is_empty())
    });

    let twitter_title = page.twitter_card.title.clone().or_else(|| title.clone());
    let twitter_description = page
        .twitter_card
        .description
        .clone()
        .or_else(|| meta_description.clone());

    let mut json_ld = Vec::new();
    json_ld.push(webpage_json_ld(
        page,
        &title,
        &meta_description,
        &canonical_url,
    ));
    if let Some(org) = organization_json_ld(page, all_pages) {
        json_ld.push(org);
    }
    if page.breadcrumbs_detected {
        json_ld.push(breadcrumb_list_json_ld(page));
    }
    for img in page.images.iter().filter(|i| !i.src.is_empty()).take(3) {
        json_ld.push(image_object_json_ld(img));
    }

    let canonical_for_og = canonical_url.clone();
    MetadataRecommendation {
        page: page.url_or_path.clone(),
        title,
        meta_description,
        canonical_url,
        open_graph: OpenGraphRecommendation {
            title: og_title,
            description: og_description,
            image: og_image.clone(),
            url: canonical_for_og,
        },
        twitter_card: TwitterRecommendation {
            card: Some("summary_large_image".to_string()),
            title: twitter_title,
            description: twitter_description,
            image: page.twitter_card.image.clone().or(og_image),
        },
        json_ld,
    }
}

fn webpage_json_ld(
    page: &HtmlPage,
    title: &Option<String>,
    description: &Option<String>,
    url: &Option<String>,
) -> serde_json::Value {
    serde_json::json!({
        "@context": "https://schema.org",
        "@type": "WebPage",
        "name": title,
        "description": description,
        "url": url,
        "inLanguage": page.language
    })
}

fn organization_json_ld(page: &HtmlPage, all_pages: &[HtmlPage]) -> Option<serde_json::Value> {
    if !is_root_page(page, all_pages) {
        return None;
    }
    Some(serde_json::json!({
        "@context": "https://schema.org",
        "@type": "Organization",
        "name": page.title,
        "url": page.canonical_urls.first().unwrap_or(&page.url_or_path)
    }))
}

fn is_root_page(page: &HtmlPage, all_pages: &[HtmlPage]) -> bool {
    let normalized = normalize_path_separators(&page.url_or_path);
    let path = normalized.trim_end_matches('/');
    if path.is_empty() || normalized == "/" || is_site_root_path(&page.url_or_path) {
        return true;
    }
    let basename = normalized
        .rsplit('/')
        .next()
        .unwrap_or_default()
        .to_ascii_lowercase();
    if basename != "index.html" && basename != "index.htm" {
        return false;
    }
    all_pages
        .iter()
        .filter(|p| is_site_root_path(&p.url_or_path))
        .count()
        <= 1
}

fn breadcrumb_list_json_ld(page: &HtmlPage) -> serde_json::Value {
    let items: Vec<_> = page
        .headings
        .iter()
        .take(5)
        .enumerate()
        .map(|(i, h)| {
            serde_json::json!({
                "@type": "ListItem",
                "position": i + 1,
                "name": h.text
            })
        })
        .collect();

    serde_json::json!({
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": items
    })
}

fn image_object_json_ld(img: &crate::html::ImageInfo) -> serde_json::Value {
    serde_json::json!({
        "@context": "https://schema.org",
        "@type": "ImageObject",
        "contentUrl": img.src,
        "description": img.alt
    })
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        s.to_string()
    } else {
        let end = char_boundary_before(s, max.saturating_sub(1));
        format!("{}", &s[..end])
    }
}

fn char_boundary_before(s: &str, max: usize) -> usize {
    if max >= s.len() {
        return s.len();
    }
    let mut boundary = 0usize;
    for (idx, _) in s.char_indices() {
        if idx > max {
            break;
        }
        boundary = idx;
    }
    boundary
}

#[cfg(test)]
mod tests {
    use super::{generate_metadata_recommendations, truncate};
    use crate::html::parse_html;

    #[test]
    fn truncate_handles_utf8_boundaries() {
        let s = "Page — metadata with multibyte text";
        let truncated = truncate(s, 10);
        assert!(truncated.ends_with(''));
    }

    #[test]
    fn organization_json_ld_only_for_root_page() {
        let root = parse_html(
            "/index.html",
            "<html><head><title>Root</title></head><body><h1>Home</h1></body></html>",
            true,
        );
        let non_root = parse_html(
            "/articles/indexing-tips.html",
            "<html><head><title>Article</title></head><body><h1>Indexing Tips</h1></body></html>",
            true,
        );
        let recs = generate_metadata_recommendations(&[root, non_root]);
        let root_has_org = recs[0]
            .json_ld
            .iter()
            .any(|v| v["@type"] == serde_json::Value::String("Organization".to_string()));
        let article_has_org = recs[1]
            .json_ld
            .iter()
            .any(|v| v["@type"] == serde_json::Value::String("Organization".to_string()));
        assert!(root_has_org);
        assert!(!article_has_org);
    }
}