• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thanos / petrify / 29260799580

13 Jul 2026 03:06PM UTC coverage: 87.769% (+2.7%) from 85.093%
29260799580

Pull #12

github

web-flow
Merge d61827138 into 4d16d77d2
Pull Request #12: New flow

152 of 159 new or added lines in 3 files covered. (95.6%)

1 existing line in 1 file now uncovered.

653 of 744 relevant lines covered (87.77%)

2.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.74
/src/html_parser.rs
1
use crate::paths;
2
use crate::types::{Resource, ResourceType};
3
use anyhow::{anyhow, Result};
4
use html5ever::Attribute;
5
use html5ever::parse_document;
6
use html5ever::tendril::TendrilSink;
7
use markup5ever_rcdom::{Handle, NodeData, RcDom};
8
use regex::Regex;
9
use std::collections::{HashMap, HashSet};
10
use url::Url;
11

12
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13
struct UrlReference {
14
    original: String,
15
    resolved: Url,
16
}
17

18
pub struct HtmlParser {
19
    base_url: Url,
20
    output_dir: String,
21
    convert_to_webp: bool,
22
}
23

24
impl HtmlParser {
25
    pub fn new(base_url: Url, output_dir: String, convert_to_webp: bool) -> Self {
3✔
26
        Self {
27
            base_url,
28
            output_dir: paths::normalize_output_dir(&output_dir),
6✔
29
            convert_to_webp,
30
        }
31
    }
32

33
    pub fn parse_html(&self, html_content: &str) -> Result<(String, Vec<Resource>)> {
3✔
34
        let dom = parse_document(RcDom::default(), Default::default())
6✔
35
            .from_utf8()
36
            .one(html_content.as_bytes());
6✔
37

38
        let mut resources = Vec::new();
3✔
39
        let mut modified_html = html_content.to_string();
6✔
40

41
        let mut references = self.extract_url_references(&dom.document)?;
6✔
42
        references.sort_by(|a, b| b.original.len().cmp(&a.original.len()));
12✔
43

44
        let mut local_paths = HashMap::new();
3✔
45

46
        for reference in &references {
9✔
47
            let lookup_key = Self::url_without_fragment(&reference.resolved);
6✔
48
            if local_paths.contains_key(&lookup_key) {
6✔
49
                continue;
50
            }
51
            match self.create_resource(&reference.resolved) {
6✔
52
                Ok(resource) => {
3✔
53
                    local_paths.insert(lookup_key, resource.local_path.clone());
3✔
54
                    resources.push(resource);
3✔
55
                }
56
                Err(e) => {
×
57
                    log::warn!(
×
58
                        "Failed to create resource for {}: {}",
59
                        reference.resolved.as_str(),
60
                        e
61
                    );
62
                }
63
            }
64
        }
65

66
        for reference in &references {
3✔
67
            let lookup_key = Self::url_without_fragment(&reference.resolved);
3✔
68
            if let Some(local_path) = local_paths.get(&lookup_key) {
9✔
69
                modified_html = self.replace_url_in_html(
6✔
70
                    &modified_html,
3✔
71
                    &reference.original,
3✔
72
                    local_path,
3✔
73
                    reference.resolved.fragment(),
3✔
74
                );
75
            }
76
        }
77

78
        Ok((modified_html, resources))
3✔
79
    }
80

81
    fn extract_url_references(&self, handle: &Handle) -> Result<Vec<UrlReference>> {
3✔
82
        let mut references = Vec::new();
3✔
83
        let mut seen = HashSet::new();
3✔
84
        self.walk_dom(handle, &mut references, &mut seen)?;
6✔
85
        Ok(references)
3✔
86
    }
87

88
    fn walk_dom(
3✔
89
        &self,
90
        handle: &Handle,
91
        references: &mut Vec<UrlReference>,
92
        seen: &mut HashSet<(String, String)>,
93
    ) -> Result<()> {
94
        let node = handle;
95

96
        match &node.data {
3✔
97
            NodeData::Element {
3✔
98
                ref name,
99
                ref attrs,
100
                ..
101
            } => {
102
                let attrs_ref = attrs.borrow();
3✔
103
                for attr in attrs_ref.iter() {
6✔
104
                    if self.is_url_attribute(&attr.name.local) {
6✔
105
                        self.push_resolved_reference(&attr.value, references, seen);
6✔
106
                    } else if self.is_css_attribute(&attr.name.local) {
6✔
107
                        self.push_css_url_references(&attr.value, references, seen);
3✔
108
                    }
109
                }
110

111
                if name.local.as_ref() == "meta" && self.is_seo_image_meta(&attrs_ref) {
6✔
112
                    if let Some(content) = attrs_ref
9✔
113
                        .iter()
114
                        .find(|a| a.name.local.as_ref() == "content")
9✔
115
                    {
116
                        self.push_resolved_reference(&content.value, references, seen);
3✔
117
                    }
118
                }
119

120
                if name.local.as_ref() == "script" && self.is_json_ld_script(&attrs_ref) {
9✔
121
                    let text = Self::collect_text_content(handle);
2✔
122
                    self.push_json_ld_url_references(&text, references, seen);
4✔
123
                }
124
            }
125
            NodeData::Text { ref contents } => {
3✔
126
                self.push_css_url_references(&contents.borrow(), references, seen);
3✔
127
            }
128
            _ => {}
129
        }
130

131
        for child in node.children.borrow().iter() {
6✔
132
            self.walk_dom(child, references, seen)?;
6✔
133
        }
134

135
        Ok(())
3✔
136
    }
137

138
    fn push_resolved_reference(
3✔
139
        &self,
140
        url_str: &str,
141
        references: &mut Vec<UrlReference>,
142
        seen: &mut HashSet<(String, String)>,
143
    ) {
144
        if let Ok(url) = self.resolve_url(url_str) {
9✔
145
            let key = (url_str.to_string(), url.to_string());
6✔
146
            if seen.insert(key) {
3✔
147
                references.push(UrlReference {
3✔
148
                    original: url_str.to_string(),
3✔
149
                    resolved: url,
3✔
150
                });
151
            }
152
        }
153
    }
154

155
    fn push_css_url_references(
3✔
156
        &self,
157
        css: &str,
158
        references: &mut Vec<UrlReference>,
159
        seen: &mut HashSet<(String, String)>,
160
    ) {
161
        if let Some(urls) = self.extract_urls_from_css(css) {
3✔
162
            for url_str in urls {
9✔
163
                self.push_resolved_reference(&url_str, references, seen);
6✔
164
            }
165
        }
166
    }
167

168
    fn is_css_attribute(&self, attr_name: &str) -> bool {
3✔
169
        attr_name == "style"
3✔
170
    }
171

172
    fn is_url_attribute(&self, attr_name: &str) -> bool {
3✔
173
        matches!(
6✔
174
            attr_name,
175
            "src"
176
                | "href"
177
                | "data-src"
178
                | "data-original"
179
                | "poster"
180
                | "background"
181
                | "data-srcset"
182
                | "data-lazy-src"
183
        )
184
    }
185

186
    fn is_seo_image_meta(&self, attrs: &[Attribute]) -> bool {
3✔
187
        attrs.iter().any(|attr| {
6✔
188
            let name = attr.name.local.as_ref();
3✔
189
            if name != "property" && name != "name" && name != "itemprop" {
6✔
190
                return false;
2✔
191
            }
192
            let value = attr.value.as_ref().to_ascii_lowercase();
3✔
193
            matches!(
8✔
194
                value.as_str(),
6✔
195
                "og:image"
3✔
196
                    | "og:image:url"
6✔
197
                    | "og:image:secure_url"
3✔
198
                    | "twitter:image"
3✔
199
                    | "twitter:image:src"
2✔
200
                    | "image"
2✔
201
                    | "thumbnail"
2✔
202
                    | "msapplication-tileimage"
2✔
203
            ) || value.starts_with("og:image:")
4✔
204
        })
205
    }
206

207
    fn is_json_ld_script(&self, attrs: &[Attribute]) -> bool {
3✔
208
        attrs.iter().any(|attr| {
6✔
209
            attr.name.local.as_ref() == "type"
3✔
210
                && attr
2✔
211
                    .value
212
                    .as_ref()
2✔
213
                    .eq_ignore_ascii_case("application/ld+json")
2✔
214
        })
215
    }
216

217
    fn collect_text_content(handle: &Handle) -> String {
2✔
218
        let mut text = String::new();
2✔
219
        Self::append_text_content(handle, &mut text);
2✔
220
        text
2✔
221
    }
222

223
    fn append_text_content(handle: &Handle, out: &mut String) {
2✔
224
        match &handle.data {
2✔
225
            NodeData::Text { ref contents } => {
2✔
226
                out.push_str(&contents.borrow());
2✔
227
            }
228
            _ => {
229
                for child in handle.children.borrow().iter() {
4✔
230
                    Self::append_text_content(child, out);
4✔
231
                }
232
            }
233
        }
234
    }
235

236
    fn push_json_ld_url_references(
2✔
237
        &self,
238
        json: &str,
239
        references: &mut Vec<UrlReference>,
240
        seen: &mut HashSet<(String, String)>,
241
    ) {
242
        // Match common schema.org image-like string fields.
243
        let Ok(re) = Regex::new(
244
            r#""(?:image|thumbnailUrl|contentUrl|logo|photo)"\s*:\s*"([^"]+)""#,
245
        ) else {
246
            return;
247
        };
248
        for cap in re.captures_iter(json) {
6✔
249
            if let Some(url) = cap.get(1) {
4✔
250
                self.push_resolved_reference(url.as_str(), references, seen);
4✔
251
            }
252
        }
253
    }
254

255
    fn extract_urls_from_css(&self, text: &str) -> Option<Vec<String>> {
3✔
256
        let mut urls = Vec::new();
3✔
257

258
        // Extract URLs from CSS @import statements
259
        let import_regex = Regex::new(r#"@import\s+["']([^"']+)["']"#).ok()?;
6✔
260
        for cap in import_regex.captures_iter(text) {
9✔
261
            if let Some(url) = cap.get(1) {
2✔
262
                urls.push(url.as_str().to_string());
2✔
263
            }
264
        }
265

266
        // Extract URLs from CSS url() functions
267
        let url_regex = Regex::new(r#"url\(["']?([^"')]+)["']?\)"#).ok()?;
3✔
268
        for cap in url_regex.captures_iter(text) {
9✔
269
            if let Some(url) = cap.get(1) {
6✔
270
                urls.push(url.as_str().to_string());
6✔
271
            }
272
        }
273

274
        if urls.is_empty() {
6✔
275
            None
3✔
276
        } else {
277
            Some(urls)
3✔
278
        }
279
    }
280

281
    fn resolve_url(&self, url_str: &str) -> Result<Url> {
3✔
282
        if url_str.trim().is_empty() {
3✔
283
            return Err(anyhow!("Skipping empty URL"));
3✔
284
        }
285

286
        if url_str.starts_with("data:") || url_str.starts_with("#") {
6✔
287
            return Err(anyhow!("Skipping data URL or fragment"));
2✔
288
        }
289

290
        if url_str.starts_with("//") {
3✔
291
            let scheme = self.base_url.scheme();
1✔
292
            Ok(Url::parse(&format!("{scheme}:{url_str}"))?)
2✔
293
        } else if url_str.starts_with("http://") || url_str.starts_with("https://") {
10✔
294
            Ok(Url::parse(url_str)?)
3✔
295
        } else {
296
            // Absolute (/...) and relative paths. Prefer join so fragments and
297
            // queries are parsed correctly — set_path() would treat "#frag" as
298
            // part of the path and encode it as %23.
299
            Ok(self.base_url.join(url_str)?)
3✔
300
        }
301
    }
302

303
    fn url_without_fragment(url: &Url) -> String {
3✔
304
        let mut stripped = url.clone();
3✔
305
        stripped.set_fragment(None);
3✔
306
        stripped.to_string()
3✔
307
    }
308

309
    fn create_resource(&self, url: &Url) -> Result<Resource> {
3✔
310
        // Fragments are client-side only; local paths must ignore them.
311
        let mut url_for_path = url.clone();
3✔
312
        url_for_path.set_fragment(None);
3✔
313

314
        let resource_type = self.determine_resource_type(&url_for_path);
3✔
315
        let local_path = self.generate_local_path(&url_for_path, &resource_type)?;
3✔
316

317
        Ok(Resource {
3✔
318
            url: url_for_path,
3✔
319
            local_path,
3✔
320
            resource_type: resource_type.clone(),
3✔
321
            mime_type: self.guess_mime_type(url, &resource_type),
3✔
322
            size: None,
323
            downloaded: false,
324
        })
325
    }
326

327
    fn determine_resource_type(&self, url: &Url) -> ResourceType {
3✔
328
        let path = url.path();
3✔
329
        let extension = path.split('.').next_back().unwrap_or("").to_lowercase();
3✔
330

331
        // Check for explicit file extensions first
332
        match extension.as_str() {
6✔
333
            "html" | "htm" => ResourceType::HTML,
3✔
334
            "css" => ResourceType::CSS,
6✔
335
            "js" | "javascript" => ResourceType::JavaScript,
6✔
336
            "jpg" | "jpeg" | "png" | "gif" | "webp" | "svg" | "ico" | "bmp" | "tiff" | "tif" => {
6✔
337
                ResourceType::Image
3✔
338
            }
339
            "mp4" | "webm" | "ogg" | "avi" | "mov" | "m4v" => ResourceType::Video,
3✔
340
            "pdf" => ResourceType::PDF,
3✔
341
            "woff" | "woff2" | "ttf" | "otf" | "eot" => ResourceType::Font,
6✔
342
            _ => {
343
                // For URLs without extensions, check if they look like HTML pages
344
                if self.looks_like_html_page(path) {
3✔
345
                    ResourceType::HTML
3✔
346
                } else {
347
                    ResourceType::Other
×
348
                }
349
            }
350
        }
351
    }
352

353
    fn looks_like_html_page(&self, path: &str) -> bool {
3✔
354
        if path.is_empty() || path == "/" {
3✔
355
            return true;
3✔
356
        }
357

358
        // Check if the path ends with a slash (directory-like)
359
        if path.ends_with('/') {
2✔
360
            return true;
2✔
361
        }
362

363
        // Check if the path looks like a content page (not a file)
364
        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
6✔
365

366
        // If it's a single segment without extension, likely a page
367
        if segments.len() == 1 && !segments[0].contains('.') {
6✔
368
            return true;
2✔
369
        }
370

371
        // If it's multiple segments and the last one doesn't have an extension, likely a page
372
        if segments.len() > 1 {
×
373
            let last_segment = segments.last().unwrap_or(&"");
×
374
            if !last_segment.contains('.') && !last_segment.is_empty() {
×
375
                return true;
×
376
            }
377
        }
378

379
        false
×
380
    }
381

382
    fn generate_local_path(&self, url: &Url, resource_type: &ResourceType) -> Result<String> {
3✔
383
        let path = url.path();
3✔
384

385
        let subdirectory = match resource_type {
3✔
386
            ResourceType::HTML => "", // HTML files maintain original structure
3✔
387
            ResourceType::CSS => "static/css",
3✔
388
            ResourceType::JavaScript => "static/js",
2✔
389
            ResourceType::Image => "static/images",
3✔
390
            ResourceType::Video => "static/video",
×
391
            ResourceType::PDF => "static/pdf",
×
392
            ResourceType::Font => "static/fonts",
2✔
393
            ResourceType::Other => "static/other",
×
394
        };
395

396
        // For HTML files, preserve the original directory structure
397
        if *resource_type == ResourceType::HTML {
3✔
398
            let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
9✔
399

400
            if path_segments.is_empty() || path == "/" {
9✔
401
                // Root page
402
                return Ok(format!("{}/index.html", self.output_dir));
6✔
403
            } else {
404
                // Create directory structure matching the original URL
405
                let dir_path = path_segments[..path_segments.len() - 1].join("/");
3✔
406
                let filename = path_segments.last().unwrap_or(&"index");
6✔
407

408
                // Handle trailing slash (directory-like URLs)
409
                if path.ends_with('/') {
3✔
410
                    if dir_path.is_empty() {
4✔
411
                        return Ok(format!("{}/{}/index.html", self.output_dir, filename));
4✔
412
                    } else {
413
                        return Ok(format!(
×
414
                            "{}/{}/{}/index.html",
415
                            self.output_dir, dir_path, filename
416
                        ));
417
                    }
418
                } else {
419
                    // Regular file path
420
                    if dir_path.is_empty() {
4✔
421
                        return Ok(format!("{}/{}.html", self.output_dir, filename));
4✔
422
                    } else {
423
                        return Ok(format!(
×
424
                            "{}/{}/{}.html",
425
                            self.output_dir, dir_path, filename
426
                        ));
427
                    }
428
                }
429
            }
430
        }
431

432
        // For non-HTML resources, use the existing logic
433
        let filename = path.split('/').next_back().unwrap_or("unknown");
3✔
434
        let mut unique_filename = filename.to_string();
3✔
435
        if filename == "index.html" || filename.is_empty() {
9✔
436
            let host = url.host_str().unwrap_or("unknown");
×
437
            let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
×
438
            if path_segments.is_empty() {
×
439
                unique_filename = format!("{}.html", host);
×
440
            } else {
441
                unique_filename = format!("{}.html", path_segments.join("_"));
×
442
            }
443
        }
444

445
        let mut local_path = format!(
6✔
446
            "{}/{}/{}",
447
            self.output_dir, subdirectory, unique_filename
448
        );
449
        if self.convert_to_webp && *resource_type == ResourceType::Image {
12✔
450
            local_path = paths::update_path_to_webp(&local_path);
3✔
451
        }
452

453
        Ok(local_path)
3✔
454
    }
455

456
    fn guess_mime_type(&self, url: &Url, resource_type: &ResourceType) -> String {
3✔
457
        let path = url.path();
3✔
458
        let extension = path.split('.').next_back().unwrap_or("").to_lowercase();
3✔
459

460
        match extension.as_str() {
6✔
461
            "html" | "htm" => "text/html".to_string(),
5✔
462
            "css" => "text/css".to_string(),
9✔
463
            "js" => "application/javascript".to_string(),
10✔
464
            "jpg" | "jpeg" => "image/jpeg".to_string(),
9✔
465
            "png" => "image/png".to_string(),
9✔
466
            "gif" => "image/gif".to_string(),
6✔
467
            "webp" => "image/webp".to_string(),
10✔
468
            "svg" => "image/svg+xml".to_string(),
6✔
469
            "ico" => "image/x-icon".to_string(),
6✔
470
            "mp4" => "video/mp4".to_string(),
6✔
471
            "webm" => "video/webm".to_string(),
6✔
472
            "ogg" => "video/ogg".to_string(),
6✔
473
            "pdf" => "application/pdf".to_string(),
6✔
474
            "woff" => "font/woff".to_string(),
6✔
475
            "woff2" => "font/woff2".to_string(),
10✔
476
            "ttf" => "font/ttf".to_string(),
8✔
477
            _ => match resource_type {
3✔
478
                ResourceType::HTML => "text/html".to_string(),
6✔
479
                ResourceType::CSS => "text/css".to_string(),
×
480
                ResourceType::JavaScript => "application/javascript".to_string(),
2✔
481
                ResourceType::Image => "image/jpeg".to_string(),
×
482
                ResourceType::Video => "video/mp4".to_string(),
×
483
                ResourceType::PDF => "application/pdf".to_string(),
×
484
                ResourceType::Font => "font/woff".to_string(),
×
485
                ResourceType::Other => "application/octet-stream".to_string(),
×
486
            },
487
        }
488
    }
489

490
    fn replace_url_in_html(
3✔
491
        &self,
492
        html: &str,
493
        original_url: &str,
494
        local_path: &str,
495
        fragment: Option<&str>,
496
    ) -> String {
497
        if original_url.is_empty() {
3✔
NEW
498
            return html.to_string();
×
499
        }
500

501
        let mut relative_path = self.make_site_path(local_path);
3✔
502
        if let Some(frag) = fragment {
6✔
503
            relative_path.push('#');
3✔
504
            relative_path.push_str(frag);
3✔
505
        }
506
        let escaped = regex::escape(original_url);
3✔
507
        let mut result = html.to_string();
6✔
508

509
        const URL_ATTRS: &str =
510
            "href|src|data-src|data-original|poster|background|data-srcset|data-lazy-src|content";
511

512
        for (quote, end) in [("\"", "\""), ("'", "'")] {
9✔
513
            let pattern = format!(r#"(?i)({URL_ATTRS})\s*=\s*{quote}{escaped}{end}"#);
6✔
514
            if let Ok(re) = Regex::new(&pattern) {
9✔
515
                result = re
6✔
516
                    .replace_all(&result, |caps: &regex::Captures| {
9✔
517
                        format!("{}={quote}{}{end}", &caps[1], relative_path)
3✔
518
                    })
519
                    .into_owned();
3✔
520
            }
521
        }
522

523
        // JSON-LD and other quoted absolute URL occurrences
524
        if original_url.starts_with("http://") || original_url.starts_with("https://") {
6✔
525
            let quoted_original = format!(r#""{original_url}""#);
6✔
526
            let quoted_replacement = format!(r#""{relative_path}""#);
6✔
527
            result = result.replace(&quoted_original, &quoted_replacement);
6✔
528
        }
529

530
        let url_pattern = format!(r#"url\(\s*['"]?{escaped}['"]?\s*\)"#);
6✔
531
        if let Ok(re) = Regex::new(&url_pattern) {
9✔
532
            result = re
6✔
533
                .replace_all(&result, format!("url({relative_path})"))
6✔
534
                .into_owned();
3✔
535
        }
536

537
        let import_pattern = format!(r#"@import\s+['"]{escaped}['"]"#);
3✔
538
        if let Ok(re) = Regex::new(&import_pattern) {
9✔
539
            result = re
6✔
540
                .replace_all(&result, format!(r#"@import "{relative_path}""#))
6✔
541
                .into_owned();
3✔
542
        }
543

544
        result
3✔
545
    }
546

547
    fn make_site_path(&self, target_local_path: &str) -> String {
3✔
548
        paths::site_root_path(&self.output_dir, target_local_path)
3✔
549
    }
550
}
551

552
#[cfg(test)]
553
mod tests {
554
    use super::*;
555

556
    #[test]
557
    fn test_url_resolution() {
558
        let base_url = Url::parse("https://example.com/page/").unwrap();
559
        let parser = HtmlParser::new(base_url, "./output".to_string(), false);
560

561
        let relative_url = "image.jpg";
562
        let resolved = parser.resolve_url(relative_url).unwrap();
563
        assert_eq!(resolved.as_str(), "https://example.com/page/image.jpg");
564
    }
565

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

571
        let image_url = Url::parse("https://example.com/image.png").unwrap();
572
        let resource_type = parser.determine_resource_type(&image_url);
573
        assert!(matches!(resource_type, ResourceType::Image));
574
    }
575

576
    #[test]
577
    fn test_protocol_relative_url_resolution() {
578
        let base_url = Url::parse("http://example.com/page/").unwrap();
579
        let parser = HtmlParser::new(base_url, "./output".to_string(), false);
580

581
        let resolved = parser.resolve_url("//cdn.example.com/lib.js").unwrap();
582
        assert_eq!(resolved.as_str(), "http://cdn.example.com/lib.js");
583
    }
584

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

590
        let about = Url::parse("https://example.com/about").unwrap();
591
        assert_eq!(parser.determine_resource_type(&about), ResourceType::HTML);
592
    }
593

594
    #[test]
595
    fn test_absolute_path_resolution() {
596
        let base_url = Url::parse("https://example.com/page/").unwrap();
597
        let parser = HtmlParser::new(base_url, "./output".to_string(), false);
598
        let resolved = parser.resolve_url("/assets/app.css").unwrap();
599
        assert_eq!(resolved.as_str(), "https://example.com/assets/app.css");
600
    }
601

602
    #[test]
603
    fn absolute_path_with_fragment_keeps_fragment_out_of_path() {
604
        let base_url = Url::parse("http://127.0.0.1:8000/").unwrap();
605
        let parser = HtmlParser::new(base_url, "./mb".to_string(), false);
606
        let resolved = parser
607
            .resolve_url("/2025-9-the-amphibian/#home")
608
            .unwrap();
609
        assert_eq!(resolved.path(), "/2025-9-the-amphibian/");
610
        assert_eq!(resolved.fragment(), Some("home"));
611
        assert!(!resolved.path().contains("%23"));
612
    }
613

614
    #[test]
615
    fn rewrites_path_with_fragment_preserving_hash() {
616
        let html = r##"<html><body>
617
            <a href="/2025-9-the-amphibian/#home">Home</a>
618
            <a href="/2025-9-the-amphibian/#program">Program</a>
619
            <a href="#local">Local</a>
620
        </body></html>"##;
621
        let base_url = Url::parse("http://127.0.0.1:8000/").unwrap();
622
        let parser = HtmlParser::new(base_url, "./mb".to_string(), false);
623
        let (modified, resources) = parser.parse_html(html).unwrap();
624

625
        assert!(
626
            resources
627
                .iter()
628
                .filter(|r| r.resource_type == ResourceType::HTML)
629
                .count()
630
                == 1,
631
            "fragment variants should map to one page resource"
632
        );
633
        assert!(modified.contains(r#"href="/2025-9-the-amphibian/index.html#home""#));
634
        assert!(modified.contains(r#"href="/2025-9-the-amphibian/index.html#program""#));
635
        assert!(modified.contains(r##"href="#local""##));
636
        assert!(!modified.contains("%23"));
637
        assert!(!modified.contains("#home.html"));
638
    }
639

640
    #[test]
641
    fn test_absolute_https_url_resolution() {
642
        let base_url = Url::parse("https://example.com/").unwrap();
643
        let parser = HtmlParser::new(base_url, "./output".to_string(), false);
644
        let resolved = parser
645
            .resolve_url("https://cdn.example.com/lib.js")
646
            .unwrap();
647
        assert_eq!(resolved.as_str(), "https://cdn.example.com/lib.js");
648
    }
649

650
    #[test]
651
    fn test_skips_data_and_fragment_urls() {
652
        let base_url = Url::parse("https://example.com/").unwrap();
653
        let parser = HtmlParser::new(base_url, "./output".to_string(), false);
654
        assert!(parser.resolve_url("#section").is_err());
655
        assert!(parser.resolve_url("data:image/png;base64,abc").is_err());
656
    }
657

658
    #[test]
659
    fn test_skips_empty_urls() {
660
        let base_url = Url::parse("https://example.com/page/").unwrap();
661
        let parser = HtmlParser::new(base_url, "./output".to_string(), false);
662
        assert!(parser.resolve_url("").is_err());
663
        assert!(parser.resolve_url("   ").is_err());
664
    }
665

666
    #[test]
667
    fn extracts_and_rewrites_seo_meta_images() {
668
        let html = r#"<html><head>
669
            <meta property="og:image" content="https://s3.amazonaws.com/bucket/cover.jpg" />
670
            <meta name="twitter:image" content="https://s3.amazonaws.com/bucket/cover.jpg">
671
            <meta name="description" content="Not an image URL">
672
            <script type="application/ld+json">
673
            {"@type":"Person","image":"https://cdn.example.com/photo.png","url":"https://example.com/about"}
674
            </script>
675
        </head></html>"#;
676
        let base_url = Url::parse("http://127.0.0.1:8000/artfestival/artist/x/").unwrap();
677
        let parser = HtmlParser::new(base_url, "./mb".to_string(), true);
678
        let (modified, resources) = parser.parse_html(html).unwrap();
679

680
        assert!(resources.iter().any(|r| r.url.path().ends_with("cover.jpg")));
681
        assert!(resources.iter().any(|r| r.url.path().ends_with("photo.png")));
682
        assert!(!resources.iter().any(|r| r.url.as_str().contains("/about")));
683
        assert!(modified.contains(r#"content="/static/images/cover.webp""#));
684
        assert!(modified.contains(r#""image":"/static/images/photo.webp""#));
685
        assert!(modified.contains(r#"content="Not an image URL""#));
686
    }
687

688
    #[test]
689
    fn empty_src_attribute_does_not_corrupt_html() {
690
        let html = r#"<html><body>
691
            <img class="uk-invisible" src="" width="" height="" alt="">
692
            <link rel="stylesheet" href="/static/app.css">
693
        </body></html>"#;
694
        let base_url = Url::parse("http://127.0.0.1:8000/2025-9-the-amphibian/").unwrap();
695
        let parser = HtmlParser::new(base_url, "./mb".to_string(), false);
696
        let (modified, resources) = parser.parse_html(html).unwrap();
697

698
        assert!(modified.contains("<!DOCTYPE html>") || modified.contains("<html>"));
699
        assert!(!modified.contains("2025-9-the-amphibian/index.html<"));
700
        assert!(modified.contains(r#"<img class="uk-invisible" src=""#));
701
        assert!(resources.iter().any(|r| r.url.path().ends_with("app.css")));
702
    }
703

704
    #[test]
705
    fn rewrites_image_urls_to_webp_when_enabled() {
706
        let html = r#"<html><body>
707
            <img src="https://s3.amazonaws.com/bucket/photo.jpg">
708
            <a href="https://s3.amazonaws.com/bucket/full-size.JPG">full</a>
709
        </body></html>"#;
710
        let base_url = Url::parse("http://127.0.0.1:8000/artfestival/artist/raed-issa/").unwrap();
711
        let parser = HtmlParser::new(base_url, "./mb".to_string(), true);
712
        let (modified, resources) = parser.parse_html(html).unwrap();
713

714
        assert!(resources.iter().all(|r| r.local_path.ends_with(".webp")));
715
        assert!(modified.contains("/static/images/photo.webp"));
716
        assert!(modified.contains("/static/images/full-size.webp"));
717
        assert!(!modified.contains("photo.jpg"));
718
        assert!(!modified.contains("full-size.JPG"));
719
    }
720

721
    #[test]
722
    fn extracts_and_rewrites_background_image_in_style_attribute() {
723
        let html = r#"<html><body>
724
            <div style="background-image: url(/static/images/2025/hero.webp);"></div>
725
        </body></html>"#;
726
        let base_url = Url::parse("http://127.0.0.1:8000/2025-9-the-amphibian/").unwrap();
727
        let parser = HtmlParser::new(base_url, "./mb".to_string(), false);
728
        let (modified, resources) = parser.parse_html(html).unwrap();
729

730
        assert!(resources.iter().any(|r| {
731
            r.resource_type == ResourceType::Image
732
                && r.url.path().ends_with("hero.webp")
733
        }));
734
        assert!(modified.contains("url(/static/images/hero.webp)"));
735
        assert!(!modified.contains("url(/static/images/2025/hero.webp)"));
736
    }
737

738
    #[test]
739
    fn extracts_urls_from_inline_css() {
740
        let html = r#"<html><head><style>
741
            @import "imported.css";
742
            body { background: url(bg.png); }
743
        </style></head></html>"#;
744
        let base_url = Url::parse("https://example.com/").unwrap();
745
        let parser = HtmlParser::new(base_url, "./output".to_string(), false);
746
        let (_, resources) = parser.parse_html(html).unwrap();
747
        assert!(resources
748
            .iter()
749
            .any(|r| r.url.path().ends_with("imported.css")));
750
        assert!(resources.iter().any(|r| r.url.path().ends_with("bg.png")));
751
    }
752

753
    #[test]
754
    fn guess_mime_type_falls_back_to_resource_type() {
755
        let base_url = Url::parse("https://example.com/").unwrap();
756
        let parser = HtmlParser::new(base_url, "./output".to_string(), false);
757
        let url = Url::parse("https://example.com/no-extension").unwrap();
758
        assert_eq!(
759
            parser.guess_mime_type(&url, &ResourceType::JavaScript),
760
            "application/javascript"
761
        );
762
    }
763

764
    #[test]
765
    fn root_href_does_not_corrupt_closing_tags() {
766
        let html = r#"<html><head></head><body>
767
            <a href="/">Home</a>
768
            <p>Visit /other/path in text</p>
769
            </body></html>"#;
770
        let base_url = Url::parse("http://127.0.0.1:8000/2025-9-the-amphibian/").unwrap();
771
        let parser = HtmlParser::new(base_url, "./mb".to_string(), false);
772
        let (modified, _) = parser.parse_html(html).unwrap();
773

774
        assert!(modified.contains("</body>"));
775
        assert!(modified.contains("</html>"));
776
        assert!(modified.contains(r#"<a href="/index.html">Home</a>"#));
777
        assert!(modified.contains("/other/path"));
778
    }
779

780
    #[test]
781
    fn make_site_path_from_page_to_asset() {
782
        let base_url = Url::parse("https://example.com/").unwrap();
783
        let parser = HtmlParser::new(base_url, "/output".to_string(), false);
784
        let path = parser.make_site_path("/output/static/css/app.css");
785
        assert_eq!(path, "/static/css/app.css");
786
    }
787

788
    #[test]
789
    fn make_site_path_from_nested_page_to_root() {
790
        let base_url = Url::parse("https://example.com/blog/post/").unwrap();
791
        let parser = HtmlParser::new(base_url, "/output".to_string(), false);
792
        let path = parser.make_site_path("/output/index.html");
793
        assert_eq!(path, "/index.html");
794
    }
795
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc