lectito 0.2.0

Article extraction and readability parsing for Lectito.
Documentation
use kuchiki::NodeRef;
use kuchiki::traits::TendrilSink;

use super::{dom, patterns};

pub fn normalize_article(nodes: &[NodeRef], title: Option<&str>) {
    for node in nodes {
        remove_duplicate_title_heading(node, title);
        normalize_code_blocks(node);
        unwrap_heading_permalinks(node);
        remove_section_permalink_glyphs(node);
        remove_wbr(node);
        collapse_redundant_breaks(node);
        remove_empty_wrappers(node);
    }
}

fn remove_section_permalink_glyphs(root: &NodeRef) {
    for anchor in dom::select_nodes(root, "h1 a, h2 a, h3 a, h4 a, h5 a, h6 a") {
        if !dom::attr(&anchor, "href").is_some_and(|href| href.starts_with('#') || href.contains('#')) {
            continue;
        }
        let text = dom::inner_text(&anchor);
        let class = dom::attr(&anchor, "class").unwrap_or_default();
        if text.trim() == "ยง" || class.split_whitespace().any(|token| token == "doc-anchor") {
            anchor.detach();
        }
    }
}

fn remove_duplicate_title_heading(root: &NodeRef, title: Option<&str>) {
    let Some(title) = title else {
        return;
    };
    let Some(heading) = dom::select_nodes(root, "h1").into_iter().next() else {
        return;
    };
    if normalized_text(&dom::inner_text(&heading)) == normalized_text(title) {
        heading.detach();
    }
}

fn normalize_code_blocks(root: &NodeRef) {
    for pre in dom::select_nodes(root, "pre") {
        if !dom::select_nodes(&pre, "code").is_empty() {
            continue;
        }
        let fragment = kuchiki::parse_html().one("<html><body><pre><code></code></pre></body></html>");
        let Some(new_pre) = dom::select_nodes(&fragment, "pre").into_iter().next() else {
            continue;
        };
        let Some(code) = dom::select_nodes(&new_pre, "code").into_iter().next() else {
            continue;
        };
        while let Some(child) = pre.first_child() {
            code.append(child);
        }
        pre.insert_before(new_pre);
        pre.detach();
    }
}

fn unwrap_heading_permalinks(root: &NodeRef) {
    for heading in dom::select_nodes(root, "h1, h2, h3, h4, h5, h6") {
        let element_children: Vec<_> = heading
            .children()
            .filter(|child| child.as_element().is_some())
            .collect();
        if element_children.len() != 1 {
            continue;
        }
        let anchor = &element_children[0];
        if dom::node_name(anchor) != "a" || !dom::attr(anchor, "href").is_some_and(|href| href.starts_with('#')) {
            continue;
        }
        dom::replace_with_children(anchor);
    }
}

fn remove_wbr(root: &NodeRef) {
    for node in dom::select_nodes(root, "wbr") {
        node.detach();
    }
}

fn collapse_redundant_breaks(root: &NodeRef) {
    for parent in dom::select_nodes(root, "*") {
        let mut run = Vec::new();
        for child in parent.children() {
            if dom::node_name(&child) == "br" {
                run.push(child);
                continue;
            }
            if run.len() > 2 {
                for extra in run.drain(2..) {
                    extra.detach();
                }
            }
            run.clear();
        }
        if run.len() > 2 {
            for extra in run.drain(2..) {
                extra.detach();
            }
        }
    }
}

fn remove_empty_wrappers(root: &NodeRef) {
    for node in dom::select_nodes(root, "div, section, header, span") {
        if dom::node_id(&node) == dom::node_id(root) {
            continue;
        }
        let has_media = !dom::select_nodes(&node, "img, iframe, video, audio, object, embed, source").is_empty();
        if !has_media && dom::inner_text(&node).is_empty() {
            node.detach();
        }
    }
}

fn normalized_text(value: &str) -> String {
    patterns::normalize_spaces(value.trim()).to_lowercase()
}