we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement link click navigation (Phase 17)

Add click-to-navigate support for <a href="..."> elements. When a user
clicks on a link (or any element nested inside an anchor), the browser
resolves the href against the current page's base URL and navigates to
the target page.

Changes:
- Add base_url field to PageState for relative URL resolution
- Add find_ancestor_anchor() to walk DOM ancestors for <a href>
- Add navigate_to_link() to resolve URLs and trigger page loads
- Modify handle_mouse_down() to detect link clicks before label delegation
- Fix form submission to use actual page base URL instead of about:blank
- Skip javascript: and fragment-only (#) hrefs gracefully
- Add 7 unit tests covering anchor detection and URL resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+219 -12
+219 -12
crates/browser/src/main.rs
··· 38 38 images: ImageStore, 39 39 /// Font registry with both system and web fonts (from `@font-face`). 40 40 font_registry: FontRegistry, 41 + /// Base URL of the current page (for resolving relative URLs in links). 42 + base_url: Url, 41 43 } 42 44 43 45 /// Browser state kept in thread-local storage so the resize handler can ··· 531 533 } 532 534 }; 533 535 534 - // 5. Resolve the action URL. 535 - // We need the document's base URL. Reconstruct it from the document URL we stored. 536 - // For now, use about:blank as fallback. 537 - let base_url = Url::parse("about:blank").unwrap(); 538 - let action_url = match resolve_action(&params.action, &base_url) { 536 + // 5. Resolve the action URL against the page's base URL. 537 + let action_url = match resolve_action(&params.action, &state.page.base_url) { 539 538 Some(url) => url, 540 539 None => { 541 540 eprintln!("[we] Invalid form action URL: {}", params.action); ··· 1457 1456 state.page.doc.set_active_element(Some(node), false); 1458 1457 } 1459 1458 rerender(state); 1460 - } else { 1461 - // No form control hit. Check for label click delegation. 1462 - let label_control = hit_test_any_element(&tree.root, view_x, view_y, 0.0, 0.0) 1463 - .and_then(|hit_node| { 1464 - find_ancestor_label(&state.page.doc, hit_node) 1465 - .and_then(|label| state.page.doc.label_control(label)) 1466 - }); 1459 + } else if let Some(hit_node) = hit_test_any_element(&tree.root, view_x, view_y, 0.0, 0.0) { 1460 + // No form control hit. Check for link click first, then label delegation. 1461 + if let Some((_anchor, href)) = find_ancestor_anchor(&state.page.doc, hit_node) { 1462 + let href = href.to_string(); 1463 + navigate_to_link(state, &href); 1464 + return; 1465 + } 1466 + 1467 + // Check for label click delegation. 1468 + let label_control = find_ancestor_label(&state.page.doc, hit_node) 1469 + .and_then(|label| state.page.doc.label_control(label)); 1467 1470 1468 1471 if let Some(control) = label_control { 1469 1472 activate_control(state, control); ··· 1474 1477 state.page.doc.set_active_element(None, false); 1475 1478 rerender(state); 1476 1479 } 1480 + } else if state.page.doc.active_element().is_some() { 1481 + // Clicked on empty area — blur. 1482 + state.page.doc.close_all_selects(); 1483 + state.page.doc.set_active_element(None, false); 1484 + rerender(state); 1477 1485 } 1478 1486 }); 1479 1487 } ··· 1730 1738 None 1731 1739 } 1732 1740 1741 + /// Walk up the DOM from `node` looking for an ancestor `<a>` element with an `href` attribute. 1742 + fn find_ancestor_anchor(doc: &Document, node: NodeId) -> Option<(NodeId, &str)> { 1743 + let mut current = Some(node); 1744 + while let Some(id) = current { 1745 + if doc.tag_name(id) == Some("a") { 1746 + if let Some(href) = doc.get_attribute(id, "href") { 1747 + return Some((id, href)); 1748 + } 1749 + } 1750 + current = doc.parent(id); 1751 + } 1752 + None 1753 + } 1754 + 1755 + /// Navigate to a link target. Resolves the href against the page base URL, 1756 + /// loads the new page, and replaces the current browsing context. 1757 + fn navigate_to_link(state: &mut BrowserState, href: &str) { 1758 + let href = href.trim(); 1759 + 1760 + // Ignore javascript: URLs. 1761 + if href.starts_with("javascript:") { 1762 + return; 1763 + } 1764 + 1765 + // Ignore fragment-only links for now (same-page scroll — Phase 17 fragment nav). 1766 + if href.starts_with('#') { 1767 + return; 1768 + } 1769 + 1770 + // Resolve the href against the current page's base URL. 1771 + let target_url = if href.is_empty() { 1772 + // Empty href reloads the current page. 1773 + state.page.base_url.clone() 1774 + } else { 1775 + match Url::parse_with_base(href, &state.page.base_url) { 1776 + Ok(url) => url, 1777 + Err(e) => { 1778 + eprintln!("[we] Invalid link URL \"{href}\": {e}"); 1779 + return; 1780 + } 1781 + } 1782 + }; 1783 + 1784 + eprintln!("[we] Navigating to: {}", target_url.serialize()); 1785 + 1786 + let loaded = load_from_url(&target_url); 1787 + let new_page = load_page(loaded); 1788 + 1789 + // Swap font: prefer web fonts from the new page, then keep existing. 1790 + let new_font = new_page 1791 + .font_registry 1792 + .find_best_font() 1793 + .or_else(|| font::load_system_font().ok()); 1794 + if let Some(f) = new_font { 1795 + state.font = f; 1796 + } 1797 + 1798 + state.page = new_page; 1799 + state.page_scroll_y = 0.0; 1800 + state.scroll_offsets.clear(); 1801 + rerender(state); 1802 + } 1803 + 1733 1804 /// Called by the platform crate on scroll wheel events. 1734 1805 fn handle_scroll(_dx: f64, dy: f64, _mouse_x: f64, _mouse_y: f64) { 1735 1806 STATE.with(|state| { ··· 1939 2010 stylesheet, 1940 2011 images, 1941 2012 font_registry, 2013 + base_url: loaded.base_url, 1942 2014 } 1943 2015 } 1944 2016 ··· 2057 2129 app.activate(); 2058 2130 app.run(); 2059 2131 } 2132 + 2133 + #[cfg(test)] 2134 + mod tests { 2135 + use super::*; 2136 + 2137 + /// Build a minimal DOM from HTML and return the document. 2138 + fn parse(html: &str) -> Document { 2139 + we_html::parse_html(html) 2140 + } 2141 + 2142 + // ----------------------------------------------------------------------- 2143 + // find_ancestor_anchor tests 2144 + // ----------------------------------------------------------------------- 2145 + 2146 + #[test] 2147 + fn find_anchor_on_anchor_element() { 2148 + let doc = parse(r#"<a href="/about">About</a>"#); 2149 + // The text node "About" is a child of <a>. 2150 + // Walk every node to find the text node. 2151 + let text_node = (0..doc.len()) 2152 + .map(NodeId::from_index) 2153 + .find(|&id| matches!(doc.node_data(id), NodeData::Text { .. })) 2154 + .expect("text node exists"); 2155 + 2156 + let result = find_ancestor_anchor(&doc, text_node); 2157 + assert!(result.is_some()); 2158 + let (anchor, href) = result.unwrap(); 2159 + assert_eq!(doc.tag_name(anchor), Some("a")); 2160 + assert_eq!(href, "/about"); 2161 + } 2162 + 2163 + #[test] 2164 + fn find_anchor_nested_span() { 2165 + let doc = parse(r#"<a href="https://example.com"><span>Click</span></a>"#); 2166 + // Find the text "Click". 2167 + let text_node = (0..doc.len()) 2168 + .map(NodeId::from_index) 2169 + .find(|&id| { 2170 + if let NodeData::Text { data } = doc.node_data(id) { 2171 + data == "Click" 2172 + } else { 2173 + false 2174 + } 2175 + }) 2176 + .expect("text node exists"); 2177 + 2178 + let result = find_ancestor_anchor(&doc, text_node); 2179 + assert!(result.is_some()); 2180 + assert_eq!(result.unwrap().1, "https://example.com"); 2181 + } 2182 + 2183 + #[test] 2184 + fn find_anchor_no_href() { 2185 + let doc = parse(r#"<a name="top">Anchor</a>"#); 2186 + let text_node = (0..doc.len()) 2187 + .map(NodeId::from_index) 2188 + .find(|&id| matches!(doc.node_data(id), NodeData::Text { .. })) 2189 + .expect("text node exists"); 2190 + 2191 + // <a> without href should not be found. 2192 + let result = find_ancestor_anchor(&doc, text_node); 2193 + assert!(result.is_none()); 2194 + } 2195 + 2196 + #[test] 2197 + fn find_anchor_not_inside_link() { 2198 + let doc = parse(r#"<div><p>Plain text</p></div>"#); 2199 + let text_node = (0..doc.len()) 2200 + .map(NodeId::from_index) 2201 + .find(|&id| { 2202 + if let NodeData::Text { data } = doc.node_data(id) { 2203 + data == "Plain text" 2204 + } else { 2205 + false 2206 + } 2207 + }) 2208 + .expect("text node exists"); 2209 + 2210 + let result = find_ancestor_anchor(&doc, text_node); 2211 + assert!(result.is_none()); 2212 + } 2213 + 2214 + // ----------------------------------------------------------------------- 2215 + // URL resolution tests (exercising the logic in navigate_to_link) 2216 + // ----------------------------------------------------------------------- 2217 + 2218 + #[test] 2219 + fn resolve_relative_url() { 2220 + let base = Url::parse("https://example.com/docs/intro").unwrap(); 2221 + let resolved = Url::parse_with_base("/about", &base).unwrap(); 2222 + assert_eq!(resolved.serialize(), "https://example.com/about"); 2223 + } 2224 + 2225 + #[test] 2226 + fn resolve_relative_path_url() { 2227 + let base = Url::parse("https://example.com/docs/intro").unwrap(); 2228 + let resolved = Url::parse_with_base("other", &base).unwrap(); 2229 + assert_eq!(resolved.serialize(), "https://example.com/docs/other"); 2230 + } 2231 + 2232 + #[test] 2233 + fn resolve_absolute_url() { 2234 + let base = Url::parse("https://example.com/page").unwrap(); 2235 + let resolved = Url::parse_with_base("https://other.com/foo", &base).unwrap(); 2236 + assert_eq!(resolved.serialize(), "https://other.com/foo"); 2237 + } 2238 + 2239 + #[test] 2240 + fn resolve_protocol_relative_url() { 2241 + let base = Url::parse("https://example.com/page").unwrap(); 2242 + let resolved = Url::parse_with_base("//cdn.example.com/style.css", &base).unwrap(); 2243 + assert_eq!(resolved.serialize(), "https://cdn.example.com/style.css"); 2244 + } 2245 + 2246 + #[test] 2247 + fn resolve_empty_href_returns_base() { 2248 + let base = Url::parse("https://example.com/page").unwrap(); 2249 + // Empty href should reload the current page — we use base_url.clone(). 2250 + let resolved = base.clone(); 2251 + assert_eq!(resolved.serialize(), "https://example.com/page"); 2252 + } 2253 + 2254 + #[test] 2255 + fn javascript_href_is_skipped() { 2256 + // Just verify the prefix check works. 2257 + let href = "javascript:void(0)"; 2258 + assert!(href.starts_with("javascript:")); 2259 + } 2260 + 2261 + #[test] 2262 + fn fragment_href_is_skipped() { 2263 + let href = "#section"; 2264 + assert!(href.starts_with('#')); 2265 + } 2266 + }