petrify 0.2.0

Petrify live websites into static offline copies
Documentation
use petrify::html_parser::HtmlParser;
use petrify::types::ResourceType;
use url::Url;

const FIXTURE_HTML: &str = include_str!("../test_site/index.html");

#[test]
fn extracts_css_js_and_images_from_fixture() {
    let base_url = Url::parse("https://example.com/").unwrap();
    let parser = HtmlParser::new(base_url, "./output".to_string(), false);

    let (_, resources) = parser.parse_html(FIXTURE_HTML).unwrap();

    assert!(
        resources
            .iter()
            .any(|r| r.resource_type == ResourceType::CSS && r.url.path().ends_with("style.css")),
        "expected stylesheet resource"
    );
    assert!(
        resources
            .iter()
            .any(|r| r.resource_type == ResourceType::JavaScript
                && r.url.path().ends_with("script.js")),
        "expected javascript resource"
    );
    assert!(
        resources
            .iter()
            .filter(|r| r.resource_type == ResourceType::Image)
            .count()
            >= 2,
        "expected at least two image resources"
    );
}

#[test]
fn rewrites_urls_to_site_root_paths() {
    let base_url = Url::parse("https://example.com/").unwrap();
    let parser = HtmlParser::new(base_url, "./mirrored".to_string(), false);

    let (modified, _) = parser.parse_html(FIXTURE_HTML).unwrap();

    assert!(modified.contains("/static/css/style.css"));
    assert!(modified.contains("/static/js/script.js"));
    assert!(!modified.contains("../static/"));
    assert!(!modified.contains("https://example.com/style.css"));
}

#[test]
fn discovers_extensionless_html_links() {
    let html =
        r#"<html><body><a href="/about">About</a><a href="/contact">Contact</a></body></html>"#;
    let base_url = Url::parse("https://example.com/").unwrap();
    let parser = HtmlParser::new(base_url, "./output".to_string(), false);

    let (_, resources) = parser.parse_html(html).unwrap();
    assert!(resources
        .iter()
        .any(|r| r.resource_type == ResourceType::HTML && r.url.path() == "/about"));
}

#[test]
fn empty_src_and_root_href_do_not_corrupt_html() {
    let html = r#"<!DOCTYPE html><html><head></head><body>
        <a href="/">Home</a>
        <img class="uk-invisible" src="" width="" height="" alt="">
        <p>Visit /docs in plain text</p>
        </body></html>"#;
    let base_url = Url::parse("http://127.0.0.1:8000/nested/page/").unwrap();
    let parser = HtmlParser::new(base_url, "./mb/".to_string(), false);
    let (modified, _) = parser.parse_html(html).unwrap();

    assert!(modified.contains("<!DOCTYPE html>"));
    assert!(modified.contains("</body>"));
    assert!(modified.contains("</html>"));
    assert!(modified.contains(r#"href="/index.html""#));
    assert!(modified.contains(r#"src="""#));
    assert!(modified.contains("/docs"));
    assert!(!modified.contains("%23"));
    assert!(!modified.contains("//static/"));
    assert!(!modified.contains("static/other/"));
}

#[test]
fn style_attribute_background_images_are_discovered_and_rewritten() {
    let html = r#"<html><body>
        <div style="background-image: url(/static/images/2025/hero.webp);"></div>
        <style>.home { background-image: url(/static/images/home.jpg); }</style>
    </body></html>"#;
    let base_url = Url::parse("http://127.0.0.1:8000/2025-9-the-amphibian/").unwrap();
    let parser = HtmlParser::new(base_url, "./mb".to_string(), false);
    let (modified, resources) = parser.parse_html(html).unwrap();

    assert!(resources
        .iter()
        .any(|r| r.url.path().ends_with("hero.webp")));
    assert!(resources.iter().any(|r| r.url.path().ends_with("home.jpg")));
    assert!(modified.contains("url(/static/images/hero.webp)"));
    assert!(modified.contains("url(/static/images/home.jpg)"));
    assert!(!modified.contains("/static/images/2025/hero.webp"));
}

#[test]
fn fragment_urls_are_rewritten_without_percent23_html_files() {
    let html = r##"<html><body>
        <a href="/2025-9-the-amphibian/#home">Home</a>
        <a href="/2025-9-the-amphibian/#program">Program</a>
        <a href="#local">Local</a>
    </body></html>"##;
    let base_url = Url::parse("http://127.0.0.1:8000/").unwrap();
    let parser = HtmlParser::new(base_url, "./mb".to_string(), false);
    let (modified, resources) = parser.parse_html(html).unwrap();

    let html_pages: Vec<_> = resources
        .iter()
        .filter(|r| r.resource_type == ResourceType::HTML)
        .collect();
    assert_eq!(html_pages.len(), 1);
    assert!(html_pages[0]
        .local_path
        .ends_with("2025-9-the-amphibian/index.html"));
    assert!(!html_pages[0].local_path.contains("%23"));

    assert!(modified.contains(r#"href="/2025-9-the-amphibian/index.html#home""#));
    assert!(modified.contains(r#"href="/2025-9-the-amphibian/index.html#program""#));
    assert!(modified.contains(r##"href="#local""##));
    assert!(!modified.contains("%23home"));
    assert!(!modified.contains("#home.html"));
}

#[test]
fn seo_meta_and_json_ld_images_are_discovered_and_rewritten() {
    let html = r#"<html><head>
        <meta property="og:image" content="https://s3.amazonaws.com/bucket/og-cover.jpeg" />
        <meta name="twitter:image" content="https://s3.amazonaws.com/bucket/og-cover.jpeg">
        <meta name="description" content="Plain text, not a URL">
        <script type="application/ld+json">
        {"@type":"Person","name":"Artist","image":"https://cdn.example.com/portrait.png","url":"https://example.com/artist"}
        </script>
    </head><body></body></html>"#;
    let base_url = Url::parse("http://127.0.0.1:8000/artfestival/artist/x/").unwrap();
    let parser = HtmlParser::new(base_url, "./mb".to_string(), true);
    let (modified, resources) = parser.parse_html(html).unwrap();

    assert!(resources.iter().any(|r| {
        r.resource_type == ResourceType::Image && r.url.path().ends_with("og-cover.jpeg")
    }));
    assert!(resources.iter().any(|r| {
        r.resource_type == ResourceType::Image && r.url.path().ends_with("portrait.png")
    }));
    assert!(!resources.iter().any(|r| r.url.path() == "/artist"));

    assert!(modified.contains(r#"content="/static/images/og-cover.webp""#));
    assert!(modified.contains(r#""image":"/static/images/portrait.webp""#));
    assert!(modified.contains(r#"content="Plain text, not a URL""#));
    assert!(modified.contains(r#""url":"https://example.com/artist""#));
}

#[test]
fn external_images_rewrite_to_webp_site_root_paths() {
    let html = r#"<html><body>
        <a href="https://s3.amazonaws.com/bucket/full.JPG"><img src="https://s3.amazonaws.com/bucket/thumb.jpg"></a>
    </body></html>"#;
    let base_url = Url::parse("http://127.0.0.1:8000/artfestival/artist/raed-issa/").unwrap();
    let parser = HtmlParser::new(base_url, "./mb".to_string(), true);
    let (modified, resources) = parser.parse_html(html).unwrap();

    assert_eq!(
        resources
            .iter()
            .filter(|r| r.resource_type == ResourceType::Image)
            .count(),
        2
    );
    assert!(resources.iter().all(|r| r.local_path.ends_with(".webp")));
    assert!(modified.contains(r#"src="/static/images/thumb.webp""#));
    assert!(modified.contains(r#"href="/static/images/full.webp""#));
    assert!(!modified.contains("../static/"));
    assert!(!modified.contains("thumb.jpg"));
    assert!(!modified.contains("full.JPG"));
}

#[test]
fn fonts_are_classified_as_font_resources() {
    let html = r#"<html><head>
        <link rel="stylesheet" href="/static/fonts/site.woff2">
        <style>@font-face { src: url(/assets/brand.ttf); }</style>
    </head></html>"#;
    let base_url = Url::parse("https://example.com/").unwrap();
    let parser = HtmlParser::new(base_url, "./output".to_string(), false);
    let (modified, resources) = parser.parse_html(html).unwrap();

    assert!(resources.iter().any(|r| {
        r.resource_type == ResourceType::Font && r.url.path().ends_with("site.woff2")
    }));
    assert!(resources
        .iter()
        .any(|r| { r.resource_type == ResourceType::Font && r.url.path().ends_with("brand.ttf") }));
    assert!(modified.contains("/static/fonts/site.woff2"));
    assert!(modified.contains("url(/static/fonts/brand.ttf)"));
}