You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [15e1037a] url stuff Side A — unified diff (full patch): diff --git a/server/src/url_rules/graph.rs b/server/src/url_rules/graph.rs new file mode 100644 index 0000000000000000000000000000000000000000..f7ac0f9a551a1727cb2f9294778c283b9885b147 --- /dev/null +++ b/server/src/url_rules/graph.rs @@ -0,0 +1,831 @@ +//! Semantic URL graph: DFA traversal on host + path, query in context, generic fallback. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use url::Url; + +use super::graph_builder::GraphBuilder; +use super::parse::{normalize_match_host, strip_tracking_query, UrlParts}; + +#[derive(Debug, Clone, Default)] +pub struct Context { + pub vars: HashMap, + pub query: HashMap, +} + +pub type CanonicalFn = fn(&Context) -> Option; + +#[derive(Clone, Copy)] +pub enum EdgePattern { + Literal(&'static str), + Variable(&'static str), + /// Absorb any trailing segment without leaving this node (e.g. post title slug). + AbsorbAny, + /// Absorb segment when `cond(seg)` (e.g. subreddit listing suffix). + AbsorbIf(fn(&str) -> bool), +} + +pub struct Edge { + pub pattern: EdgePattern, + pub target: &'static str, +} + +pub struct Node { + pub edges: Vec, + pub canonical: CanonicalFn, + pub parent: Option<&'static str>, +} + +impl Node { + pub(crate) fn empty() -> Self { + Self { + edges: Vec::new(), + canonical: |_| None, + parent: None, + } + } +} + +pub struct Graph { + pub nodes: HashMap<&'static str, Node>, +} + +static GRAPH: OnceLock = OnceLock::new(); + +pub fn graph() -> &'static Graph { + GRAPH.get_or_init(build_graph) +} + +impl Graph { + pub fn resolve_canonical(&self, parts: &UrlParts) -> Option { + let mut query = parts.query.clone(); + strip_tracking_query(&mut query); + let mut ctx = Context { + vars: HashMap::new(), + query, + }; + + if let Some(node_id) = self.traverse(parts, &mut ctx) { + if let Some(canon) = (self.nodes.get(node_id)?.canonical)(&ctx) { + return Some(canon); + } + } + Some(generic_canonical(parts)) + } + + pub fn breadcrumbs(&self, parts: &UrlParts) -> Vec { + let mut query = parts.query.clone(); + strip_tracking_query(&mut query); + let mut ctx = Context { + vars: HashMap::new(), + query, + }; + + if let Some(mut node_id) = self.traverse(parts, &mut ctx) { + let mut paths = Vec::new(); + loop { + let node = match self.nodes.get(node_id) { + Some(n) => n, + None => break, + }; + if let Some(url) = (node.canonical)(&ctx) { + if paths.last() != Some(&url) { + paths.push(url); + } + } + match node.parent { + Some(p) => node_id = p, + None => break, + } + } + paths.reverse(); + if !paths.is_empty() { + return paths; + } + } + generic_breadcrumbs(parts) + } + + fn traverse(&self, parts: &UrlParts, ctx: &mut Context) -> Option<&'static str> { + let host = parts.match_host(); + let mut node_id = match host.as_str() { + "reddit.com" => "reddit_root", + "youtube.com" => "youtube_root", + "youtu.be" => "youtu_be_entry", + _ => return None, + }; + + let segs: Vec<&str> = parts.path_segments.iter().map(String::as_str).collect(); + let mut i = 0; + while i < segs.len() { + let seg = segs[i]; + match self.follow_edge(node_id, seg, ctx) { + Ok(next) => { + node_id = next; + i += 1; + } + Err(()) => { + if self.try_absorb(node_id, seg) { + i += 1; + continue; + } + return None; + } + } + } + Some(node_id) + } + + fn follow_edge( + &self, + node_id: &'static str, + seg: &str, + ctx: &mut Context, + ) -> Result<&'static str, ()> { + let node = self.nodes.get(node_id).ok_or(())?; + for edge in &node.edges { + match edge.pattern { + EdgePattern::Literal(lit) if lit == seg => return Ok(edge.target), + EdgePattern::Variable(name) => { + ctx.vars.insert(name.to_string(), seg.to_string()); + return Ok(edge.target); + } + EdgePattern::AbsorbAny + | EdgePattern::AbsorbIf(_) + | EdgePattern::Literal(_) + | EdgePattern::Variable(_) => {} + } + } + Err(()) + } + + fn try_absorb(&self, node_id: &'static str, seg: &str) -> bool { + let node = match self.nodes.get(node_id) { + Some(n) => n, + None => return false, + }; + for edge in &node.edges { + match edge.pattern { + EdgePattern::AbsorbAny => return true, + EdgePattern::AbsorbIf(cond) if cond(seg) => return true, + EdgePattern::AbsorbIf(_) | EdgePattern::Literal(_) | EdgePattern::Variable(_) => {} + } + } + false + } + + /// Test hook: terminal graph node and captured context after traversal. + #[cfg(test)] + pub fn traverse_terminal(&self, parts: &UrlParts) -> Option<(&'static str, Context)> { + let mut query = parts.query.clone(); + strip_tracking_query(&mut query); + let mut ctx = Context { + vars: HashMap::new(), + query, + }; + let node = self.traverse(parts, &mut ctx)?; + Some((node, ctx)) + } +} + +fn is_reddit_listing_suffix(seg: &str) -> bool { + matches!(seg, "hot" | "top" | "new" | "rising" | "controversial") +} + +/// Percent-encode a path or query fragment so `&`, `?`, etc. cannot break URL structure. +fn enc(s: &str) -> String { + urlencoding::encode(s).into_owned() +} + +// --- Canonical formatters --- + +fn canon_reddit_root(_: &Context) -> Option { + Some("https://reddit.com".to_string()) +} + +fn canon_reddit_r_hub(_: &Context) -> Option { + Some("https://reddit.com/r".to_string()) +} + +fn canon_reddit_subreddit(ctx: &Context) -> Option { + let sub = ctx.vars.get("subreddit")?; + Some(format!( + "https://reddit.com/r/{}", + enc(&sub.to_ascii_lowercase()) + )) +} + +fn canon_reddit_post(ctx: &Context) -> Option { + let sub = ctx.vars.get("subreddit")?.to_ascii_lowercase(); + let id = ctx.vars.get("post_id")?; + Some(format!( + "https://reddit.com/r/{}/comments/{}", + enc(&sub), + enc(id) + )) +} + +fn canon_youtube_root(_: &Context) -> Option { + Some("https://youtube.com".to_string()) +} + +fn canon_youtube_watch(ctx: &Context) -> Option { + let v = ctx + .query + .get("v") + .or_else(|| ctx.vars.get("video_id"))?; + Some(format!("https://youtube.com/watch?v={}", enc(v))) +} + +fn canon_youtu_be(ctx: &Context) -> Option { + let v = ctx.vars.get("vid_id")?; + Some(format!("https://youtube.com/watch?v={}", enc(v))) +} + +pub fn build_graph() -> Graph { + GraphBuilder::new() + .node("reddit_root") + .canonical(canon_reddit_root) + .edge(EdgePattern::Literal("r"), "reddit_r_hub") + .node("reddit_r_hub") + .parent("reddit_root") + .canonical(canon_reddit_r_hub) + .edge(EdgePattern::Variable("subreddit"), "reddit_subreddit") + .node("reddit_subreddit") + .parent("reddit_r_hub") + .canonical(canon_reddit_subreddit) + .edge( + EdgePattern::AbsorbIf(is_reddit_listing_suffix), + "reddit_subreddit", + ) + .edge(EdgePattern::Literal("comments"), "reddit_comments_gate") + .node("reddit_comments_gate") + .parent("reddit_subreddit") + .canonical(canon_reddit_subreddit) + .edge(EdgePattern::Variable("post_id"), "reddit_post") + .node("reddit_post") + .parent("reddit_subreddit") + .canonical(canon_reddit_post) + .edge(EdgePattern::AbsorbAny, "reddit_post") + .node("youtube_root") + .canonical(canon_youtube_root) + .edge(EdgePattern::Literal("watch"), "youtube_watch") + .edge(EdgePattern::Literal("shorts"), "youtube_shorts_gate") + .node("youtube_watch") + .parent("youtube_root") + .canonical(canon_youtube_watch) + .node("youtube_shorts_gate") + .parent("youtube_root") + .canonical(canon_youtube_root) + .edge(EdgePattern::Variable("video_id"), "youtube_watch") + .node("youtu_be_entry") + .canonical(canon_youtube_root) + .edge(EdgePattern::Variable("vid_id"), "youtu_be_video") + .node("youtu_be_video") + .parent("youtube_root") + .canonical(canon_youtu_be) + .build() +} + +// --- Generic internet fallback --- + +pub fn generic_canonical(parts: &UrlParts) -> String { + let host = normalize_match_host(&parts.host); + let path_segments: Vec = parts.path_segments.clone(); + let mut query = parts.query.clone(); + strip_tracking_query(&mut query); + + let mut url = if path_segments.is_empty() { + Url::parse(&format!("https://{host}")) + .unwrap_or_else(|_| Url::parse("https://invalid").unwrap()) + } else { + let path = format!("/{}", path_segments.join("/")); + Url::parse(&format!("https://{host}{path}")) + .unwrap_or_else(|_| Url::parse("https://invalid").unwrap()) + }; + + if !query.is_empty() { + let mut pairs: Vec<_> = query.iter().collect(); + pairs.sort_by(|a, b| a.0.cmp(b.0)); + url.query_pairs_mut().clear(); + for (k, v) in pairs { + url.query_pairs_mut().append_pair(k, v); + } + } + + let mut s = url.to_string(); + if path_segments.is_empty() { + s = s.trim_end_matches('/').to_string(); + } + s +} + +pub fn generic_breadcrumbs(parts: &UrlParts) -> Vec { + let host = normalize_match_host(&parts.host); + let n = parts.path_segments.len(); + let mut out = Vec::new(); + + let base = generic_canonical(&UrlParts { + scheme: "https".to_string(), + host: host.clone(), + path_segments: vec![], + query: HashMap::new(), + }); + out.push(base); + + for i in 0..n { + let segs: Vec = parts.path_segments[..=i].to_vec(); + let url = generic_canonical(&UrlParts { + scheme: "https".to_string(), + host: host.clone(), + path_segments: segs, + query: HashMap::new(), + }); + if out.last() != Some(&url) { + out.push(url); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::url_rules::parse::test_parts; + + fn g() -> &'static Graph { + graph() + } + + fn canon(parts: &UrlParts) -> String { + g().resolve_canonical(parts).unwrap() + } + + fn crumbs(parts: &UrlParts) -> Vec { + g().breadcrumbs(parts) + } + + fn terminal(parts: &UrlParts) -> Option<&'static str> { + g().traverse_terminal(parts).map(|(n, _)| n) + } + + fn vars(parts: &UrlParts) -> HashMap { + g().traverse_terminal(parts) + .map(|(_, c)| c.vars) + .unwrap_or_default() + } + + #[test] + fn youtu_be_malicious_segment_encoded_not_injected() { + let p = test_parts("youtu.be", &["abc&t=1"], &[]); + assert_eq!(canon(&p), "https://youtube.com/watch?v=abc%26t%3D1"); + assert!(!canon(&p).contains("abc&t=1")); + } + + #[test] + fn youtube_query_v_encoded() { + let p = test_parts("youtube.com", &["watch"], &[("v", "a&b=c")]); + assert_eq!(canon(&p), "https://youtube.com/watch?v=a%26b%3Dc"); + } + + #[test] + fn absorb_patterns_live_on_edges_not_in_engine() { + let g = build_graph(); + let sub = g.nodes.get("reddit_subreddit").unwrap(); + assert!(sub + .edges + .iter() + .any(|e| matches!(e.pattern, EdgePattern::AbsorbIf(_)))); + let post = g.nodes.get("reddit_post").unwrap(); + assert!(post + .edges + .iter() + .any(|e| matches!(e.pattern, EdgePattern::AbsorbAny))); + } + + #[test] + fn builder_rejects_missing_parent() { + let result = std::panic::catch_unwind(|| { + GraphBuilder::new() + .node("orphan") + .parent("nonexistent_parent") + .build(); + }); + assert!(result.is_err()); + } + + #[test] + fn generic_canonical_forces_https_and_strips_www() { + let p = test_parts("www.example.com", &["blog", "post"], &[]); + assert_eq!(canon(&p), "https://example.com/blog/post"); + } + + #[test] + fn generic_canonical_sorts_query_keys() { + let p = test_parts("example.com", &["search"], &[("q", "rust"), ("page", "2")]); + assert_eq!(canon(&p), "https://example.com/search?page=2&q=rust"); + } + + #[test] + fn generic_canonical_strips_tracking_from_query() { + let p = test_parts( + "news.ycombinator.com", + &["item"], + &[("id", "1"), ("utm_medium", "social")], + ); + assert_eq!(canon(&p), "https://news.ycombinator.com/item?id=1"); + } + + #[test] + fn generic_breadcrumbs_cumulative_path() { + let p = test_parts("paulgraham.com", &["articles", "lisp.html"], &[]); + assert_eq!( + crumbs(&p), + vec![ + "https://paulgraham.com", + "https://paulgraham.com/articles", + "https://paulgraham.com/articles/lisp.html" + ] + ); + } + + #[test] + fn generic_breadcrumbs_domain_only() { + let p = test_parts("example.com", &[], &[]); + assert_eq!(crumbs(&p), vec!["https://example.com"]); + } + + #[test] + fn unknown_host_uses_generic_not_graph() { + let p = test_parts("hackernews.com", &["item", "123"], &[]); + assert_eq!(terminal(&p), None); + assert_eq!(canon(&p), "https://hackernews.com/item/123"); + } + + #[test] + fn traverse_captures_subreddit_variable() { + let p = test_parts("reddit.com", &["r", "Rust"], &[]); + assert_eq!(terminal(&p), Some("reddit_subreddit")); + assert_eq!(vars(&p).get("subreddit").map(String::as_str), Some("Rust")); + } + + #[test] + fn traverse_captures_post_id() { + let p = test_parts("reddit.com", &["r", "aww", "comments", "abc123"], &[]); + assert_eq!(terminal(&p), Some("reddit_post")); + assert_eq!(vars(&p).get("post_id").map(String::as_str), Some("abc123")); + } + + #[test] + fn traverse_absorbs_listing_suffix_stays_on_subreddit() { + let p = test_parts("reddit.com", &["r", "rust", "hot"], &[]); + assert_eq!(terminal(&p), Some("reddit_subreddit")); + assert_eq!(canon(&p), "https://reddit.com/r/rust"); + } + + #[test] + fn traverse_absorbs_all_listing_suffixes() { + for suffix in ["hot", "top", "new", "rising", "controversial"] { + let p = test_parts("reddit.com", &["r", "test", suffix], &[]); + assert_eq!(terminal(&p), Some("reddit_subreddit"), "suffix {suffix}"); + assert_eq!(canon(&p), "https://reddit.com/r/test", "suffix {suffix}"); + } + } + + #[test] + fn traverse_absorbs_post_title_slug() { + let p = test_parts( + "reddit.com", + &["r", "rust", "comments", "aaa", "my_great_post_title"], + &[], + ); + assert_eq!(terminal(&p), Some("reddit_post")); + assert_eq!(canon(&p), "https://reddit.com/r/rust/comments/aaa"); + } + + #[test] + fn traverse_unknown_segment_falls_back_to_generic() { + let p = test_parts("reddit.com", &["r", "rust", "wiki", "faq"], &[]); + assert_eq!(terminal(&p), None); + assert_eq!(canon(&p), "https://reddit.com/r/rust/wiki/faq"); + } + + #[test] + fn traverse_youtube_watch_requires_v_in_query() { + let p = test_parts("youtube.com", &["watch"], &[("v", "xyz")]); + assert_eq!(terminal(&p), Some("youtube_watch")); + } + + #[test] + fn traverse_youtu_be_captures_vid_id() { + let p = test_parts("youtu.be", &["dQw4w9WgXcQ"], &[]); + assert_eq!(terminal(&p), Some("youtu_be_video")); + assert_eq!(vars(&p).get("vid_id").map(String::as_str), Some("dQw4w9WgXcQ")); + } + + #[test] + fn traverse_shorts_sets_video_id_var() { + let p = test_parts("youtube.com", &["shorts", "abc99"], &[]); + assert_eq!(terminal(&p), Some("youtube_watch")); + assert_eq!(vars(&p).get("video_id").map(String::as_str), Some("abc99")); + } + + #[test] + fn reddit_domain_canonical() { + let p = test_parts("reddit.com", &[], &[]); + assert_eq!(canon(&p), "https://reddit.com"); + } + + #[test] + fn reddit_r_hub_canonical() { + let p = test_parts("reddit.com", &["r"], &[]); + assert_eq!(terminal(&p), Some("reddit_r_hub")); + assert_eq!(canon(&p), "https://reddit.com/r"); + } + + #[test] + fn reddit_subreddit_lowercases_name() { + let p = test_parts("reddit.com", &["r", "AmITheAsshole"], &[]); + assert_eq!(canon(&p), "https://reddit.com/r/amitheasshole"); + } + + #[test] + fn reddit_host_aliases_old_new_www() { + for host in ["old.reddit.com", "new.reddit.com", "www.reddit.com"] { + let p = test_parts(host, &["r", "rust"], &[]); + assert_eq!(canon(&p), "https://reddit.com/r/rust", "host {host}"); + } + } + + #[test] + fn reddit_post_strips_slug_and_query() { + let p = test_parts( + "old.reddit.com", + &["r", "Rust", "comments", "1abc", "title_slug_here"], + &[("sort", "new")], + ); + assert_eq!(canon(&p), "https://reddit.com/r/rust/comments/1abc"); + } + + #[test] + fn reddit_post_multiple_slugs_absorbed() { + let p = test_parts( + "reddit.com", + &["r", "x", "comments", "id1", "slug1", "extra"], + &[], + ); + assert_eq!(canon(&p), "https://reddit.com/r/x/comments/id1"); + } + + #[test] + fn reddit_listing_with_query_only() { + let p = test_parts("www.reddit.com", &["r", "programming"], &[("sort", "top")]); + assert_eq!(canon(&p), "https://reddit.com/r/programming"); + } + + #[test] + fn reddit_subreddit_breadcrumbs_include_r_hub() { + let p = test_parts("reddit.com", &["r", "movies"], &[]); + assert_eq!( + crumbs(&p), + vec![ + "https://reddit.com", + "https://reddit.com/r", + "https://reddit.com/r/movies" + ] + ); + } + + #[test] + fn reddit_post_breadcrumbs_skip_comments_node() { + let p = test_parts("reddit.com", &["r", "aww", "comments", "1trnvdl"], &[]); + let c = crumbs(&p); + assert!(!c.iter().any(|u| u.ends_with("/comments"))); + assert_eq!( + c.last().map(String::as_str), + Some("https://reddit.com/r/aww/comments/1trnvdl") + ); + assert!(c.contains(&"https://reddit.com/r/aww".to_string())); + } + + #[test] + fn reddit_post_parent_is_subreddit_not_comments() { + let p = test_parts("reddit.com", &["r", "aww", "comments", "1trnvdl"], &[]); + let c = crumbs(&p); + let parent = c.get(c.len() - 2).unwrap(); + assert_eq!(parent, "https://reddit.com/r/aww"); + } + + #[test] + fn reddit_domain_parent_is_none_in_breadcrumb_chain() { + let p = test_parts("reddit.com", &[], &[]); + assert_eq!(crumbs(&p), vec!["https://reddit.com"]); + } + + #[test] + fn youtube_watch_canonical_uses_v_only() { + let p = test_parts("youtube.com", &["watch"], &[("v", "abc"), ("t", "99")]); + assert_eq!(canon(&p), "https://youtube.com/watch?v=abc"); + } + + #[test] + fn youtube_query_order_independent() { + let a = test_parts("youtube.com", &["watch"], &[("v", "abc"), ("t", "4")]); + let b = test_parts("youtube.com", &["watch"], &[("t", "4"), ("v", "abc")]); + assert_eq!(canon(&a), canon(&b)); + } + + #[test] + fn youtube_host_aliases() { + for host in ["www.youtube.com", "m.youtube.com"] { + let p = test_parts(host, &["watch"], &[("v", "x")]); + assert_eq!(canon(&p), "https://youtube.com/watch?v=x", "host {host}"); + } + } + + #[test] + fn youtube_shorts_canonical_matches_watch() { + let shorts = test_parts("youtube.com", &["shorts", "vid123"], &[]); + let watch = test_parts("youtube.com", &["watch"], &[("v", "vid123")]); + assert_eq!(canon(&shorts), canon(&watch)); + assert_eq!(canon(&shorts), "https://youtube.com/watch?v=vid123"); + } + + #[test] + fn youtu_be_matches_youtube_watch() { + let be = test_parts("youtu.be", &["dQw4w9WgXcQ"], &[]); + let watch = test_parts("youtube.com", &["watch"], &[("v", "dQw4w9WgXcQ")]); + assert_eq!(canon(&be), canon(&watch)); + } + + #[test] + fn youtube_breadcrumbs_domain_then_watch() { + let p = test_parts("youtube.com", &["watch"], &[("v", "abc")]); + assert_eq!( + crumbs(&p), + vec!["https://youtube.com", "https://youtube.com/watch?v=abc"] + ); + } + + #[test] + fn youtu_be_breadcrumbs_include_youtube_domain() { + let p = test_parts("youtu.be", &["abc"], &[]); + let c = crumbs(&p); + assert_eq!(c.first().map(String::as_str), Some("https://youtube.com")); + assert_eq!( + c.last().map(String::as_str), + Some("https://youtube.com/watch?v=abc") + ); + } + + #[test] + fn parsed_urls_match_hand_built_parts() { + let raw = "https://www.reddit.com/r/rust/comments/aaa/title/?utm=x"; + let parsed = UrlParts::parse(raw).unwrap(); + let hand = test_parts( + "www.reddit.com", + &["r", "rust", "comments", "aaa", "title"], + &[("utm", "x")], + ); + assert_eq!(canon(&parsed), canon(&hand)); + } + + #[test] + fn equivalence_cluster_youtube_formats() { + let urls = [ + "https://youtu.be/abc123", + "https://www.youtube.com/watch?v=abc123", + "https://youtube.com/watch?v=abc123&t=1", + "https://m.youtube.com/watch?t=1&v=abc123", + ]; + let canonical: Vec<_> = urls + .iter() + .map(|u| canon(&UrlParts::parse(u).unwrap())) + .collect(); + assert!(canonical.iter().all(|c| *c == "https://youtube.com/watch?v=abc123")); + } + + #[test] + fn equivalence_cluster_reddit_post_formats() { + let urls = [ + "https://old.reddit.com/r/Rust/comments/aaa/slug/", + "reddit.com/r/rust/comments/aaa/other_slug", + "https://reddit.com/r/RUST/comments/aaa", + ]; + let canonical: Vec<_> = urls + .iter() + .map(|u| canon(&UrlParts::parse(u).unwrap())) + .collect(); + assert!( + canonical + .iter() + .all(|c| *c == "https://reddit.com/r/rust/comments/aaa") + ); + } + + #[test] + fn graph_nodes_all_have_valid_parent_links() { + let g = build_graph(); + for (id, node) in &g.nodes { + if let Some(parent) = node.parent { + assert!(g.nodes.contains_key(parent), "node {id} parent {parent}"); + } + } + } + + #[test] + fn graph_terminal_canonical_always_succeeds_for_reddit_paths() { + let cases: &[(&[&str], &str)] = &[ + (&["r", "rust"], "https://reddit.com/r/rust"), + ( + &["r", "rust", "comments", "x"], + "https://reddit.com/r/rust/comments/x", + ), + ]; + for (segs, want) in cases { + let p = test_parts("reddit.com", segs, &[]); + assert_eq!(canon(&p), *want); + } + } + + #[test] + fn breadcrumb_parent_walk_matches_parent_url_semantics() { + let p = test_parts("reddit.com", &["r", "aww", "comments", "id1"], &[]); + let c = crumbs(&p); + assert_eq!(c.len(), 4); + assert_eq!( + c.get(c.len() - 2).map(String::as_str), + Some("https://reddit.com/r/aww") + ); + } + + #[test] + fn youtube_watch_without_v_falls_back_to_generic() { + let p = test_parts("youtube.com", &["watch"], &[]); + assert_eq!(terminal(&p), Some("youtube_watch")); + assert_eq!(canon(&p), "https://youtube.com/watch"); + } + + #[test] + fn reddit_only_comments_path_stops_at_gate() { + let p = test_parts("reddit.com", &["r", "rust", "comments"], &[]); + assert_eq!(terminal(&p), Some("reddit_comments_gate")); + assert_eq!(canon(&p), "https://reddit.com/r/rust"); + } + + #[test] + fn generic_deep_path_many_segments() { + let segs: Vec<&str> = (0..10) + .map(|i| match i { + 0 => "a", + 1 => "b", + 2 => "c", + 3 => "d", + 4 => "e", + 5 => "f", + 6 => "g", + 7 => "h", + 8 => "i", + _ => "j", + }) + .collect(); + let p = test_parts("site.com", &segs, &[]); + assert_eq!(crumbs(&p).len(), 11); + } + + #[test] + fn traverse_literal_r_required_for_subreddit() { + let p = test_parts("reddit.com", &["rust"], &[]); + assert_eq!(terminal(&p), None); + } + + #[test] + fn http_scheme_upgraded_via_generic_fallback_host() { + let parsed = UrlParts::parse("http://example.com/page").unwrap(); + assert_eq!(canon(&parsed), "https://example.com/page"); + } + + #[test] + fn each_graph_node_canonical_is_invokable() { + let g = build_graph(); + let empty = Context::default(); + for (id, node) in &g.nodes { + let _ = (node.canonical)(&empty); + let _ = id; + } + } + + #[test] + fn reddit_double_listing_suffix_both_absorbed() { + let p = test_parts("reddit.com", &["r", "rust", "hot", "new"], &[]); + assert_eq!(terminal(&p), Some("reddit_subreddit")); + assert_eq!(canon(&p), "https://reddit.com/r/rust"); + } + + #[test] + fn youtu_be_empty_path_stays_at_entry() { + let p = test_parts("youtu.be", &[], &[]); + assert_eq!(terminal(&p), Some("youtu_be_entry")); + } +} diff --git a/server/src/url_rules/graph_builder.rs b/server/src/url_rules/graph_builder.rs new file mode 100644 index 0000000000000000000000000000000000000000..243204ad5ca514fff459057956080cacb5bf38e2 --- /dev/null +++ b/server/src/url_rules/graph_builder.rs @@ -0,0 +1,73 @@ +//! Declarative construction of the URL graph with build-time link validation. + +use std::collections::HashMap; + +use super::graph::{CanonicalFn, Edge, EdgePattern, Graph, Node}; + +pub struct GraphBuilder { + nodes: HashMap<&'static str, Node>, + current: Option<&'static str>, +} + +impl GraphBuilder { + pub fn new() -> Self { + Self { + nodes: HashMap::new(), + current: None, + } + } + + pub fn node(mut self, id: &'static str) -> Self { + self.nodes.entry(id).or_insert_with(Node::empty); + self.current = Some(id); + self + } + + pub fn canonical(mut self, f: CanonicalFn) -> Self { + let id = self.current.expect("canonical() without node()"); + self.nodes.get_mut(id).expect("node missing").canonical = f; + self + } + + pub fn parent(mut self, parent_id: &'static str) -> Self { + let id = self.current.expect("parent() without node()"); + self.nodes.get_mut(id).expect("node missing").parent = Some(parent_id); + self + } + + pub fn edge(mut self, pattern: EdgePattern, target: &'static str) -> Self { + let id = self.current.expect("edge() without node()"); + self.nodes + .get_mut(id) + .expect("node missing") + .edges + .push(Edge { pattern, target }); + self + } + + pub fn build(self) -> Graph { + for (id, node) in &self.nodes { + if let Some(parent) = node.parent { + assert!( + self.nodes.contains_key(parent), + "node {id}: parent {parent} does not exist" + ); + } + for edge in &node.edges { + if !matches!( + edge.pattern, + EdgePattern::AbsorbAny | EdgePattern::AbsorbIf(_) + ) { + assert!( + self.nodes.contains_key(edge.target), + "node {id}: edge target {} does not exist", + edge.target + ); + } + } + } + Graph { + nodes: self.nodes, + } + } +} diff --git a/server/src/url_rules/mod.rs b/server/src/url_rules/mod.rs index 9e1445346ce77a49dd6a7e7713bf9c57aef353cc..ba4ac662acc7bd4d50ee34613eb7eb6fccbfb17b 100644 --- a/server/src/url_rules/mod.rs +++ b/server/src/url_rules/mod.rs @@ -1,6 +1,7 @@ //! URL canonicalization and hierarchy via a semantic graph (DFA + generic fallback). mod graph; +mod graph_builder; mod parse; mod registry; diff --git a/server/src/url_rules/parse.rs b/server/src/url_rules/parse.rs new file mode 100644 index 0000000000000000000000000000000000000000..19d0c82feb718817168b8b445fcbfa39cf6aa6ba --- /dev/null +++ b/server/src/url_rules/parse.rs @@ -0,0 +1,169 @@ +//! Parse raw strings into host, path segments, and query (order-independent). + +use std::collections::HashMap; + +use url::Url; + +#[derive(Debug, Clone)] +pub struct UrlParts { + pub scheme: String, + pub host: String, + pub path_segments: Vec, + pub query: HashMap, +} + +impl UrlParts { + pub fn parse(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let with_scheme = if trimmed.contains("://") { + trimmed.to_string() + } else if trimmed.starts_with("r/") || trimmed.starts_with("/r/") { + let rest = trimmed.trim_start_matches('/').trim_start_matches("r/"); + format!("https://reddit.com/r/{rest}") + } else if trimmed.contains('.') && !trimmed.starts_with('/') { + format!("https://{trimmed}") + } else { + trimmed.to_string() + }; + + let url = Url::parse(&with_scheme).ok()?; + let host = url.host_str()?.to_string(); + let path_segments: Vec = url + .path_segments() + .map(|segs| segs.filter(|s| !s.is_empty()).map(str::to_string).collect()) + .unwrap_or_default(); + + let mut query = HashMap::new(); + for (k, v) in url.query_pairs() { + query.insert(k.into_owned(), v.into_owned()); + } + + Some(Self { + scheme: url.scheme().to_string(), + path_segments, + query, + host, + }) + } + + /// Host normalized for graph entry matching (lowercase, aliases). + pub fn match_host(&self) -> String { + normalize_match_host(&self.host) + } +} + +pub fn normalize_match_host(host: &str) -> String { + let h = host + .strip_prefix("www.") + .unwrap_or(host) + .to_ascii_lowercase(); + match h.as_str() { + "old.reddit.com" | "new.reddit.com" => "reddit.com".to_string(), + "m.youtube.com" => "youtube.com".to_string(), + _ => h, + } +} + +pub fn strip_tracking_query(query: &mut HashMap) { + query.retain(|k, _| { + let lower = k.to_ascii_lowercase(); + !(lower.starts_with("utm_") + || matches!( + lower.as_str(), + "fbclid" | "gclid" | "ref" | "ref_src" | "ref_source" | "mc_cid" | "mc_eid" + )) + }); +} + +#[cfg(test)] +pub(crate) fn test_parts(host: &str, segs: &[&str], query: &[(&str, &str)]) -> UrlParts { + UrlParts { + scheme: "https".to_string(), + host: host.to_string(), + path_segments: segs.iter().map(|s| (*s).to_string()).collect(), + query: query + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_full_url_splits_host_path_query() { + let p = UrlParts::parse("https://www.youtube.com/watch?v=abc&t=4").unwrap(); + assert_eq!(p.host, "www.youtube.com"); + assert_eq!(p.path_segments, vec!["watch"]); + assert_eq!(p.query.get("v").map(String::as_str), Some("abc")); + assert_eq!(p.query.get("t").map(String::as_str), Some("4")); + } + + #[test] + fn parse_r_shortcut_expands_to_reddit() { + let p = UrlParts::parse("r/rust").unwrap(); + assert_eq!(p.match_host(), "reddit.com"); + assert_eq!(p.path_segments, vec!["r", "rust"]); + } + + #[test] + fn parse_slash_r_shortcut() { + let p = UrlParts::parse("/r/aww").unwrap(); + assert_eq!(p.path_segments, vec!["r", "aww"]); + } + + #[test] + fn parse_schemeless_host_path() { + let p = UrlParts::parse("reddit.com/r/rust/comments/aaa/slug").unwrap(); + assert_eq!(p.match_host(), "reddit.com"); + assert_eq!( + p.path_segments, + vec!["r", "rust", "comments", "aaa", "slug"] + ); + } + + #[test] + fn parse_empty_returns_none() { + assert!(UrlParts::parse("").is_none()); + assert!(UrlParts::parse(" ").is_none()); + } + + #[test] + fn normalize_match_host_reddit_aliases() { + assert_eq!(normalize_match_host("old.reddit.com"), "reddit.com"); + assert_eq!(normalize_match_host("NEW.reddit.com"), "reddit.com"); + assert_eq!(normalize_match_host("www.reddit.com"), "reddit.com"); + } + + #[test] + fn normalize_match_host_youtube_aliases() { + assert_eq!(normalize_match_host("m.youtube.com"), "youtube.com"); + assert_eq!(normalize_match_host("www.youtube.com"), "youtube.com"); + } + + #[test] + fn strip_tracking_query_removes_known_params() { + let mut q = HashMap::from([ + ("v".into(), "1".into()), + ("utm_source".into(), "x".into()), + ("fbclid".into(), "y".into()), + ("ref".into(), "z".into()), + ]); + strip_tracking_query(&mut q); + assert_eq!(q.len(), 1); + assert_eq!(q.get("v").map(String::as_str), Some("1")); + } + + #[test] + fn strip_tracking_query_utm_prefix() { + let mut q = HashMap::from([("utm_campaign".into(), "email".into())]); + strip_tracking_query(&mut q); + assert!(q.is_empty()); + } +} diff --git a/server/src/url_rules/registry_tests.rs b/server/src/url_rules/registry_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..7b76b550f808f590e011469fdae02adf603b43d2 --- /dev/null +++ b/server/src/url_rules/registry_tests.rs @@ -0,0 +1,195 @@ +//! End-to-end tests for the public registry API (`canonicalize_raw`, breadcrumbs, parent). + +use super::registry::{ + canonicalize_raw, looks_like_url, navigable_breadcrumbs, parent_url, resolve_id, +}; + +fn canon(raw: &str) -> String { + canonicalize_raw(raw).unwrap().canonical +} + +#[test] +fn looks_like_url_positive_cases() { + for raw in [ + "https://reddit.com/r/rust", + "r/rust", + "/r/aww", + "reddit.com/r/x", + "www.example.com/path", + "youtu.be/abc", + ] { + assert!(looks_like_url(raw), "{raw}"); + } +} + +#[test] +fn looks_like_url_negative_cases() { + for raw in ["alpha", "beta", "", "hello world", "no-dots"] { + assert!(!looks_like_url(raw), "{raw}"); + } +} + +#[test] +fn resolve_id_matches_canonicalize_raw() { + let raw = "https://youtu.be/xyz"; + assert_eq!( + resolve_id(raw).as_deref(), + Some(canon(raw).as_str()) + ); +} + +#[test] +fn canonicalize_empty_returns_none() { + assert!(canonicalize_raw("").is_none()); +} + +#[test] +fn alias_when_slug_stripped() { + let r = canonicalize_raw( + "https://reddit.com/r/rust/comments/aaa/very_long_title_slug", + ) + .unwrap(); + assert_eq!(r.canonical, "https://reddit.com/r/rust/comments/aaa"); + assert!(r.alias_of.is_some()); +} + +#[test] +fn alias_none_when_already_canonical() { + let raw = "https://reddit.com/r/rust"; + let r = canonicalize_raw(raw).unwrap(); + assert_eq!(r.canonical, raw); + assert!(r.alias_of.is_none()); +} + +#[test] +fn parent_url_subreddit_under_r_hub() { + assert_eq!( + parent_url("https://reddit.com/r/movies").as_deref(), + Some("https://reddit.com/r") + ); +} + +#[test] +fn parent_url_domain_has_none() { + assert_eq!(parent_url("https://reddit.com").as_deref(), None); +} + +#[test] +fn parent_url_generic_site() { + assert_eq!( + parent_url("https://example.com/a/b").as_deref(), + Some("https://example.com/a") + ); +} + +#[test] +fn breadcrumbs_from_canonical_string_roundtrip() { + let id = "https://reddit.com/r/golang/comments/abc123"; + let crumbs = navigable_breadcrumbs(id); + assert_eq!(crumbs.last().map(String::as_str), Some(id)); +} + +// --- Table: Reddit raw URLs → canonical --- + +#[test] +fn reddit_canonical_matrix() { + let cases: &[(&str, &str)] = &[ + ("r/rust", "https://reddit.com/r/rust"), + ("/r/aww", "https://reddit.com/r/aww"), + ("https://reddit.com/r/rust", "https://reddit.com/r/rust"), + ( + "https://www.reddit.com/r/programming/new", + "https://reddit.com/r/programming", + ), + ( + "https://old.reddit.com/r/test/comments/xyz/slug/", + "https://reddit.com/r/test/comments/xyz", + ), + ( + "reddit.com/r/Movies/comments/abc/Title_Case_Slug", + "https://reddit.com/r/movies/comments/abc", + ), + ]; + for (raw, want) in cases { + assert_eq!(canon(raw), *want, "raw={raw}"); + } +} + +// --- Table: YouTube raw URLs → canonical --- + +#[test] +fn youtube_canonical_matrix() { + let cases: &[(&str, &str)] = &[ + ( + "https://youtube.com/watch?v=abc", + "https://youtube.com/watch?v=abc", + ), + ( + "https://www.youtube.com/watch?v=abc&t=1&feature=share", + "https://youtube.com/watch?v=abc", + ), + ("https://youtu.be/abc", "https://youtube.com/watch?v=abc"), + ( + "https://youtube.com/shorts/abc", + "https://youtube.com/watch?v=abc", + ), + ]; + for (raw, want) in cases { + assert_eq!(canon(raw), *want, "raw={raw}"); + } +} + +// --- Table: generic sites --- + +#[test] +fn generic_canonical_matrix() { + let cases: &[(&str, &str)] = &[ + ( + "https://news.ycombinator.com/item?id=38472", + "https://news.ycombinator.com/item?id=38472", + ), + ( + "https://www.github.com/rust-lang/rust/issues/1?utm_source=x", + "https://github.com/rust-lang/rust/issues/1", + ), + ("https://example.com", "https://example.com"), + ]; + for (raw, want) in cases { + assert_eq!(canon(raw), *want, "raw={raw}"); + } +} + +// --- Phantom /comments/ regression (sorter2-specific) --- + +#[test] +fn phantom_comments_not_in_breadcrumbs_for_post() { + let crumbs = navigable_breadcrumbs("https://reddit.com/r/rust/comments/aaa"); + assert!(!crumbs.iter().any(|c| c.ends_with("/comments"))); +} + +#[test] +fn phantom_comments_not_sibling_of_subreddit_in_breadcrumb_chain() { + let crumbs = navigable_breadcrumbs("https://reddit.com/r/rust/comments/aaa"); + let subs: Vec<_> = crumbs + .iter() + .filter(|c| c.contains("/r/rust") && !c.contains("/comments/")) + .collect(); + assert_eq!(subs, vec!["https://reddit.com/r/rust"]); +} + +// --- Distinct items must stay distinct --- + +#[test] +fn different_posts_different_canonical() { + let a = canon("https://reddit.com/r/rust/comments/aaa"); + let b = canon("https://reddit.com/r/rust/comments/bbb"); + assert_ne!(a, b); +} + +#[test] +fn different_subreddits_different_canonical() { + assert_ne!( + canon("https://reddit.com/r/rust"), + canon("https://reddit.com/r/golang") + ); +} Side B — contributor: tommy-mor Side B — commit message: [23c8134e] Fix /-/ external garden index; resolvers/ + GitHub import cards (#150) * Fix external garden root listing; add resolvers/ with GitHub cards The public and room external index pages queried children of a bogus https://./ parent, so /-/ always looked empty. Collect host-only https roots from all Web items and item_children edges so ghost parents from add_child_edge appear. Move GitHub resolver into server/src/resolvers/ with default_external.rs and a try_render_resolver_item_body hook. Resolver ingests now store slug-github-card fenced JSON; render_item_body_in_scope shows a small GitHub article card (with legacy support for schema-less json fences on github.com URLs). Styling in theme_default.css; agents.md updated. Co-authored-by: tommy * Vote compare: GitHub cards in columns, layout CSS, tests Pass item_bodies into vote_compare_item_card for linkified tooltips on non-card bodies; clone item_bodies before dropping reducer read guard. Add layout rules so rich cards sit in the grid corners (default + retro). Unit test on vote_compare_item_card; integration GET /vote/compare with ingested slug-github-card bodies. agents.md clarifies compare columns. Co-authored-by: tommy --------- Co-authored-by: Cursor Agent Side B — unified diff (full patch): diff --git a/agents.md b/agents.md index 7508234d9b04223d0e64cfe69fedbebd06a256b5..d8b801e454fdf37e7ac6038b91a69f83b0746d59 100644 --- a/agents.md +++ b/agents.md @@ -42,7 +42,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
    `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes. -- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. +- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. Implementation lives under **`server/src/resolvers/`** (GitHub resolver + import card JSON); ontology item pages and the **`GET /vote/compare`** left/right columns use **`render_item_body_in_scope`** in **`server/src/html/mod.rs`**, which calls **`server/src/resolvers/mod.rs::try_render_resolver_item_body`** before falling back to the usual **`
    `** linkified view.
     
     - **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.
     
    diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
    index 4b0214d18b173cd506d09176104f461dc4c4f208..c9eb8e242072e41fcf70da838bdf02dd4c838db8 100644
    --- a/server/src/api/ui_html.rs
    +++ b/server/src/api/ui_html.rs
    @@ -18,7 +18,7 @@ use crate::{
             rpc::{rpc_post_redact, rpc_post_with_bearer, rpc_room_delete},
         },
         canonical_path::canonicalize_tag,
    -    external_resolver::resolve_github_children,
    +    resolvers::resolve_github_children,
         html::vote_compare_post_success_js,
         html::{
             external_resolver_status_markup, fragment_new_thread_slot, login_to_post_hint_markup,
    diff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs
    deleted file mode 100644
    index a5812250fed7613950b5417f396f886a55fafccf..0000000000000000000000000000000000000000
    --- a/server/src/external_resolver.rs
    +++ /dev/null
    @@ -1,630 +0,0 @@
    -use async_trait::async_trait;
    -use serde_json::Value;
    -use tokio::sync::oneshot;
    -
    -use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd};
    -
    -const GITHUB_SYSTEM_PRINCIPAL: &str = "system:github-resolver";
    -const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000;
    -const GITHUB_MAX_PAGES: usize = 3;
    -
    -fn now_ms() -> i64 {
    -    use std::time::{SystemTime, UNIX_EPOCH};
    -    SystemTime::now()
    -        .duration_since(UNIX_EPOCH)
    -        .unwrap_or_default()
    -        .as_millis() as i64
    -}
    -
    -#[derive(Debug, Clone, PartialEq, Eq)]
    -pub struct ResolvedChild {
    -    pub url: String,
    -    pub title: String,
    -    pub body: Option,
    -}
    -
    -#[async_trait]
    -pub trait ExternalResolver: Send + Sync {
    -    /// e.g. `"github.com"`
    -    fn domain_match(&self) -> &'static str;
    -
    -    /// Normalizes URLs (e.g. stripping fragments); extend per-domain later.
    -    fn normalize(&self, path: &str) -> String;
    -
    -    /// Fetches body when missing; GitHub hook lands here in a follow-up.
    -    async fn fetch_body(&self, item: &ItemId) -> Result;
    -}
    -
    -#[derive(Clone)]
    -pub struct GitHubResolver {
    -    client: reqwest::Client,
    -    api_base_url: String,
    -    token: Option,
    -}
    -
    -impl GitHubResolver {
    -    pub fn from_env() -> Self {
    -        let api_base_url = std::env::var("SLUG_GITHUB_API_BASE_URL")
    -            .ok()
    -            .filter(|s| !s.trim().is_empty())
    -            .unwrap_or_else(|| "https://api.github.com".to_string());
    -        let token = std::env::var("SLUG_GITHUB_TOKEN")
    -            .ok()
    -            .filter(|s| !s.trim().is_empty());
    -        Self {
    -            client: reqwest::Client::new(),
    -            api_base_url: api_base_url.trim_end_matches('/').to_string(),
    -            token,
    -        }
    -    }
    -
    -    pub fn can_resolve_children(&self, item: &ItemId) -> bool {
    -        github_segments(item).is_some()
    -    }
    -
    -    pub async fn list_children(&self, item: &ItemId) -> Result, String> {
    -        let segments = github_segments(item).ok_or_else(|| "not a GitHub URL".to_string())?;
    -        match segments.as_slice() {
    -            [] => Ok(vec![]),
    -            [owner] => self.list_repos(owner).await,
    -            [owner, repo] => Ok(github_repo_sections(owner, repo)),
    -            [owner, repo, section] if section == "issues" => self.list_issues(owner, repo).await,
    -            [owner, repo, section] if section == "pulls" => self.list_pulls(owner, repo).await,
    -            [owner, repo, section] if section == "commits" => self.list_commits(owner, repo).await,
    -            [owner, repo, section] if section == "releases" => {
    -                self.list_releases(owner, repo).await
    -            }
    -            _ => Ok(vec![]),
    -        }
    -    }
    -
    -    async fn get_json(&self, path: &str) -> Result {
    -        let url = format!("{}/{}", self.api_base_url, path.trim_start_matches('/'));
    -        let mut req = self
    -            .client
    -            .get(url)
    -            .header(reqwest::header::USER_AGENT, "slugsocial-github-resolver");
    -        if let Some(token) = &self.token {
    -            req = req.bearer_auth(token);
    -        }
    -        let resp = req
    -            .send()
    -            .await
    -            .map_err(|e| format!("GitHub request failed: {e}"))?;
    -        let status = resp.status();
    -        if !status.is_success() {
    -            return Err(format!("GitHub request returned {status}"));
    -        }
    -        resp.json::()
    -            .await
    -            .map_err(|e| format!("GitHub response JSON failed: {e}"))
    -    }
    -
    -    async fn get_json_array_pages(&self, path: &str) -> Result, String> {
    -        let sep = if path.contains('?') { '&' } else { '?' };
    -        let mut out = Vec::new();
    -        for page in 1..=GITHUB_MAX_PAGES {
    -            let value = self.get_json(&format!("{path}{sep}page={page}")).await?;
    -            let arr = value
    -                .as_array()
    -                .ok_or_else(|| "GitHub paged response was not an array".to_string())?;
    -            let n = arr.len();
    -            out.extend(arr.iter().cloned());
    -            if n < 100 {
    -                break;
    -            }
    -        }
    -        Ok(out)
    -    }
    -
    -    async fn list_repos(&self, owner: &str) -> Result, String> {
    -        let arr = self
    -            .get_json_array_pages(&format!(
    -                "/users/{owner}/repos?per_page=100&sort=updated&type=owner"
    -            ))
    -            .await?;
    -        let mut out = Vec::new();
    -        for repo in &arr {
    -            let name = repo
    -                .get("name")
    -                .and_then(|v| v.as_str())
    -                .unwrap_or_default();
    -            if name.is_empty() {
    -                continue;
    -            }
    -            let full_name = repo
    -                .get("full_name")
    -                .and_then(|v| v.as_str())
    -                .map(|s| s.to_ascii_lowercase())
    -                .unwrap_or_else(|| format!("{owner}/{name}").to_ascii_lowercase());
    -            out.push(ResolvedChild {
    -                url: format!("https://github.com/{full_name}"),
    -                title: full_name.clone(),
    -                body: Some(github_repo_body(repo)),
    -            });
    -        }
    -        out.sort_by(|a, b| a.url.cmp(&b.url));
    -        Ok(out)
    -    }
    -
    -    async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> {
    -        let arr = self
    -            .get_json_array_pages(&format!(
    -                "/repos/{owner}/{repo}/issues?state=open&per_page=100"
    -            ))
    -            .await?;
    -        let mut out = Vec::new();
    -        for issue in &arr {
    -            if issue.get("pull_request").is_some() {
    -                continue;
    -            }
    -            let Some(number) = issue.get("number").and_then(|v| v.as_i64()) else {
    -                continue;
    -            };
    -            let title = issue
    -                .get("title")
    -                .and_then(|v| v.as_str())
    -                .unwrap_or("Untitled issue");
    -            out.push(ResolvedChild {
    -                url: format!("https://github.com/{owner}/{repo}/issues/{number}"),
    -                title: format!("#{number} {title}"),
    -                body: Some(github_issue_body(issue, "issue")),
    -            });
    -        }
    -        out.sort_by(|a, b| a.url.cmp(&b.url));
    -        Ok(out)
    -    }
    -
    -    async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> {
    -        let arr = self
    -            .get_json_array_pages(&format!(
    -                "/repos/{owner}/{repo}/pulls?state=open&per_page=100"
    -            ))
    -            .await?;
    -        let mut out = Vec::new();
    -        for pull in &arr {
    -            let Some(number) = pull.get("number").and_then(|v| v.as_i64()) else {
    -                continue;
    -            };
    -            let title = pull
    -                .get("title")
    -                .and_then(|v| v.as_str())
    -                .unwrap_or("Untitled pull request");
    -            out.push(ResolvedChild {
    -                url: format!("https://github.com/{owner}/{repo}/pulls/{number}"),
    -                title: format!("#{number} {title}"),
    -                body: Some(github_issue_body(pull, "pull request")),
    -            });
    -        }
    -        out.sort_by(|a, b| a.url.cmp(&b.url));
    -        Ok(out)
    -    }
    -
    -    async fn list_commits(&self, owner: &str, repo: &str) -> Result, String> {
    -        let arr = self
    -            .get_json_array_pages(&format!("/repos/{owner}/{repo}/commits?per_page=100"))
    -            .await?;
    -        let mut out = Vec::new();
    -        for commit in &arr {
    -            let Some(sha) = github_string(commit, "sha") else {
    -                continue;
    -            };
    -            let short = sha.chars().take(7).collect::();
    -            let title = commit
    -                .get("commit")
    -                .and_then(|c| c.get("message"))
    -                .and_then(|v| v.as_str())
    -                .and_then(|m| m.lines().next())
    -                .filter(|s| !s.trim().is_empty())
    -                .unwrap_or("commit");
    -            let url = github_string(commit, "html_url")
    -                .map(|s| s.to_string())
    -                .unwrap_or_else(|| format!("https://github.com/{owner}/{repo}/commit/{sha}"));
    -            out.push(ResolvedChild {
    -                url,
    -                title: format!("{short} {title}"),
    -                body: Some(github_commit_body(commit)),
    -            });
    -        }
    -        out.sort_by(|a, b| a.url.cmp(&b.url));
    -        Ok(out)
    -    }
    -
    -    async fn list_releases(&self, owner: &str, repo: &str) -> Result, String> {
    -        let arr = self
    -            .get_json_array_pages(&format!("/repos/{owner}/{repo}/releases?per_page=100"))
    -            .await?;
    -        let mut out = Vec::new();
    -        for release in &arr {
    -            let Some(tag) = github_string(release, "tag_name") else {
    -                continue;
    -            };
    -            let title = github_string(release, "name").unwrap_or(tag);
    -            let url = github_string(release, "html_url")
    -                .map(|s| s.to_string())
    -                .unwrap_or_else(|| format!("https://github.com/{owner}/{repo}/releases/tag/{tag}"));
    -            out.push(ResolvedChild {
    -                url,
    -                title: title.to_string(),
    -                body: Some(github_release_body(release)),
    -            });
    -        }
    -        out.sort_by(|a, b| a.url.cmp(&b.url));
    -        Ok(out)
    -    }
    -}
    -
    -fn github_segments(item: &ItemId) -> Option> {
    -    let url = url::Url::parse(item.as_str()).ok()?;
    -    if url.host_str()?.eq_ignore_ascii_case("github.com") {
    -        Some(
    -            url.path_segments()
    -                .map(|segments| {
    -                    segments
    -                        .filter(|s| !s.is_empty())
    -                        .map(|s| s.to_ascii_lowercase())
    -                        .collect::>()
    -                })
    -                .unwrap_or_default(),
    -        )
    -    } else {
    -        None
    -    }
    -}
    -
    -fn github_repo_sections(owner: &str, repo: &str) -> Vec {
    -    [
    -        ("issues", "GitHub issues for this repository."),
    -        ("pulls", "GitHub pull requests for this repository."),
    -        ("commits", "GitHub commits for this repository."),
    -        ("releases", "GitHub releases for this repository."),
    -    ]
    -    .into_iter()
    -    .map(|(section, body)| ResolvedChild {
    -        url: format!("https://github.com/{owner}/{repo}/{section}"),
    -        title: section.to_string(),
    -        body: Some(body.to_string()),
    -    })
    -    .collect()
    -}
    -
    -fn resolver_thread_tag(item: &ItemId) -> String {
    -    let tail = item
    -        .display_path()
    -        .trim_start_matches("-/")
    -        .replace('/', ":")
    -        .replace('?', ":");
    -    format!("import:{tail}")
    -}
    -
    -fn sanitize_body(s: &str) -> String {
    -    s.replace('{', "(")
    -        .replace('}', ")")
    -        .replace("```", "` ` `")
    -        .chars()
    -        .take(4_000)
    -        .collect()
    -}
    -
    -fn github_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
    -    value
    -        .get(key)
    -        .and_then(|v| v.as_str())
    -        .filter(|s| !s.trim().is_empty())
    -}
    -
    -fn github_user_login(value: &Value) -> Option<&str> {
    -    value
    -        .get("user")
    -        .and_then(|u| u.get("login"))
    -        .and_then(|v| v.as_str())
    -        .filter(|s| !s.trim().is_empty())
    -}
    -
    -fn github_labels(value: &Value) -> Vec {
    -    value
    -        .get("labels")
    -        .and_then(|v| v.as_array())
    -        .into_iter()
    -        .flat_map(|labels| labels.iter())
    -        .filter_map(|label| label.get("name").and_then(|v| v.as_str()))
    -        .filter(|name| !name.trim().is_empty())
    -        .map(|name| name.to_string())
    -        .collect()
    -}
    -
    -fn github_repo_body(repo: &Value) -> String {
    -    let full_name = github_string(repo, "full_name")
    -        .or_else(|| github_string(repo, "name"))
    -        .unwrap_or("GitHub repository");
    -    let mut lines = vec![full_name.to_string()];
    -    if let Some(desc) = github_string(repo, "description") {
    -        lines.push(String::new());
    -        lines.push(desc.to_string());
    -    }
    -    if let Some(url) = github_string(repo, "html_url") {
    -        lines.push(String::new());
    -        lines.push(format!("Source: {url}"));
    -    }
    -    if let Some(lang) = github_string(repo, "language") {
    -        lines.push(format!("Language: {lang}"));
    -    }
    -    lines.join("\n")
    -}
    -
    -fn github_issue_body(issue: &Value, kind: &str) -> String {
    -    let number = issue
    -        .get("number")
    -        .and_then(|v| v.as_i64())
    -        .map(|n| format!("#{n} "))
    -        .unwrap_or_default();
    -    let title = github_string(issue, "title").unwrap_or("Untitled");
    -    let state = github_string(issue, "state").unwrap_or("unknown");
    -    let mut lines = vec![format!("{kind} {number}{title}")];
    -    lines.push(format!("State: {state}"));
    -    if let Some(author) = github_user_login(issue) {
    -        lines.push(format!("Author: @{author}"));
    -    }
    -    let labels = github_labels(issue);
    -    if !labels.is_empty() {
    -        lines.push(format!("Labels: {}", labels.join(", ")));
    -    }
    -    if let Some(url) = github_string(issue, "html_url") {
    -        lines.push(format!("Source: {url}"));
    -    }
    -    if let Some(body) = github_string(issue, "body") {
    -        lines.push(String::new());
    -        lines.push(body.to_string());
    -    }
    -    lines.join("\n")
    -}
    -
    -fn github_commit_body(commit: &Value) -> String {
    -    let sha = github_string(commit, "sha").unwrap_or("unknown");
    -    let short = sha.chars().take(7).collect::();
    -    let commit_obj = commit.get("commit");
    -    let message = commit_obj
    -        .and_then(|c| c.get("message"))
    -        .and_then(|v| v.as_str())
    -        .unwrap_or("commit");
    -    let mut lines = vec![format!("commit {short}")];
    -    if let Some(author) = commit_obj
    -        .and_then(|c| c.get("author"))
    -        .and_then(|a| a.get("name"))
    -        .and_then(|v| v.as_str())
    -        .filter(|s| !s.trim().is_empty())
    -    {
    -        lines.push(format!("Author: {author}"));
    -    }
    -    if let Some(login) = github_user_login(commit) {
    -        lines.push(format!("GitHub user: @{login}"));
    -    }
    -    if let Some(date) = commit_obj
    -        .and_then(|c| c.get("author"))
    -        .and_then(|a| a.get("date"))
    -        .and_then(|v| v.as_str())
    -    {
    -        lines.push(format!("Date: {date}"));
    -    }
    -    if let Some(url) = github_string(commit, "html_url") {
    -        lines.push(format!("Source: {url}"));
    -    }
    -    lines.push(String::new());
    -    lines.push(message.to_string());
    -    lines.join("\n")
    -}
    -
    -fn github_release_body(release: &Value) -> String {
    -    let tag = github_string(release, "tag_name").unwrap_or("untagged");
    -    let title = github_string(release, "name").unwrap_or(tag);
    -    let mut lines = vec![format!("release {title}")];
    -    lines.push(format!("Tag: {tag}"));
    -    if release
    -        .get("draft")
    -        .and_then(|v| v.as_bool())
    -        .unwrap_or(false)
    -    {
    -        lines.push("Draft: yes".to_string());
    -    }
    -    if release
    -        .get("prerelease")
    -        .and_then(|v| v.as_bool())
    -        .unwrap_or(false)
    -    {
    -        lines.push("Prerelease: yes".to_string());
    -    }
    -    if let Some(author) = github_user_login(release) {
    -        lines.push(format!("Author: @{author}"));
    -    }
    -    if let Some(published) = github_string(release, "published_at") {
    -        lines.push(format!("Published: {published}"));
    -    }
    -    if let Some(url) = github_string(release, "html_url") {
    -        lines.push(format!("Source: {url}"));
    -    }
    -    if let Some(body) = github_string(release, "body") {
    -        lines.push(String::new());
    -        lines.push(body.to_string());
    -    }
    -    lines.join("\n")
    -}
    -
    -fn children_to_dsl(children: &[ResolvedChild]) -> String {
    -    let mut out = String::new();
    -    for child in children {
    -        let body = child
    -            .body
    -            .as_deref()
    -            .filter(|s| !s.trim().is_empty())
    -            .unwrap_or(child.title.as_str());
    -        if body.trim_start().starts_with("```") {
    -            out.push_str(&format!("{} {{\n{}\n}}\n\n", child.url, body.trim()));
    -        } else {
    -            out.push_str(&format!(
    -                "{} {{\n{}\n}}\n\n",
    -                child.url,
    -                sanitize_body(body)
    -            ));
    -        }
    -    }
    -    out
    -}
    -
    -pub async fn resolve_github_children(
    -    state: &AppState,
    -    room: &str,
    -    item: &ItemId,
    -) -> Result {
    -    if !state.github_resolver.can_resolve_children(item) {
    -        return Err("no GitHub resolver for this item".to_string());
    -    }
    -
    -    let key = format!("github:{}:{}", room.trim(), item.as_str());
    -    let now = now_ms();
    -    {
    -        let mut runs = state.resolver_runs.write().await;
    -        if let Some(last) = runs.get(&key) {
    -            let remaining = GITHUB_RESOLVER_COOLDOWN_MS - (now - *last);
    -            if remaining > 0 {
    -                return Err(format!(
    -                    "GitHub resolver cooldown: try again in {}s",
    -                    (remaining + 999) / 1000
    -                ));
    -            }
    -        }
    -        runs.insert(key, now);
    -    }
    -
    -    let children = state.github_resolver.list_children(item).await?;
    -    if children.is_empty() {
    -        return Ok(0);
    -    }
    -    let text = children_to_dsl(&children);
    -    let thread_tag = resolver_thread_tag(item);
    -    let (tx, rx) = oneshot::channel();
    -    state
    -        .write_tx
    -        .send(WriteCmd::SystemIngest {
    -            room: room.to_string(),
    -            thread_tag,
    -            text,
    -            principal: GITHUB_SYSTEM_PRINCIPAL.to_string(),
    -            reply: tx,
    -        })
    -        .await
    -        .map_err(|_| "writer unavailable".to_string())?;
    -    rx.await
    -        .map_err(|_| "writer dropped".to_string())?
    -        .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!("{msg}: {h}")))?;
    -    Ok(children.len())
    -}
    -
    -/// Placeholder until other domain-specific resolvers exist.
    -pub struct DefaultExternalResolver;
    -
    -#[async_trait]
    -impl ExternalResolver for DefaultExternalResolver {
    -    fn domain_match(&self) -> &'static str {
    -        ""
    -    }
    -
    -    fn normalize(&self, path: &str) -> String {
    -        path.to_string()
    -    }
    -
    -    async fn fetch_body(&self, _item: &ItemId) -> Result {
    -        Err("external fetch not implemented".to_string())
    -    }
    -}
    -
    -#[cfg(test)]
    -mod tests {
    -    use super::*;
    -
    -    #[test]
    -    fn github_segments_parse_normalized_url() {
    -        let item = ItemId::parse("https://github.com/Sortersocial/Slug/issues").unwrap();
    -        assert_eq!(
    -            github_segments(&item),
    -            Some(vec![
    -                "sortersocial".to_string(),
    -                "slug".to_string(),
    -                "issues".to_string()
    -            ])
    -        );
    -    }
    -
    -    #[test]
    -    fn repo_sections_are_direct_children() {
    -        let sections = github_repo_sections("sortersocial", "slug");
    -        let urls: Vec = sections.into_iter().map(|c| c.url).collect();
    -        assert!(urls.contains(&"https://github.com/sortersocial/slug/issues".to_string()));
    -        assert!(urls.contains(&"https://github.com/sortersocial/slug/pulls".to_string()));
    -    }
    -
    -    #[test]
    -    fn children_to_dsl_contains_item_bodies() {
    -        let dsl = children_to_dsl(&[ResolvedChild {
    -            url: "https://github.com/o/r/issues/1".into(),
    -            title: "#1 title".into(),
    -            body: Some("body with {braces}".into()),
    -        }]);
    -        assert!(dsl.contains("https://github.com/o/r/issues/1"));
    -        assert!(dsl.contains("body with (braces)"));
    -    }
    -
    -    #[test]
    -    fn children_to_dsl_preserves_fenced_json_bodies() {
    -        let dsl = children_to_dsl(&[ResolvedChild {
    -            url: "https://github.com/o/r/issues/1".into(),
    -            title: "#1 title".into(),
    -            body: Some("```json\n{\"test\": true}\n```".into()),
    -        }]);
    -        assert!(dsl.contains("https://github.com/o/r/issues/1 {\n```json"));
    -        assert!(dsl.contains("{\"test\": true}"));
    -        assert!(dsl.contains("```\n}\n"));
    -    }
    -
    -    #[test]
    -    fn github_issue_body_is_readable_text_not_json_dump() {
    -        let issue = serde_json::json!({
    -            "number": 12,
    -            "title": "Render children",
    -            "state": "open",
    -            "html_url": "https://github.com/o/r/issues/12",
    -            "user": {"login": "octo"},
    -            "labels": [{"name": "bug"}],
    -            "body": "The issue body."
    -        });
    -        let body = github_issue_body(&issue, "issue");
    -        assert!(body.contains("issue #12 Render children"));
    -        assert!(body.contains("Author: @octo"));
    -        assert!(body.contains("The issue body."));
    -        assert!(!body.trim_start().starts_with("```json"));
    -    }
    -
    -    #[test]
    -    fn github_commit_and_release_bodies_are_readable() {
    -        let commit = serde_json::json!({
    -            "sha": "abcdef123456",
    -            "html_url": "https://github.com/o/r/commit/abcdef123456",
    -            "author": {"login": "octo"},
    -            "commit": {
    -                "message": "Fix vote page\n\nDetails here.",
    -                "author": {"name": "Octo Dev", "date": "2026-05-17T00:00:00Z"}
    -            }
    -        });
    -        let release = serde_json::json!({
    -            "tag_name": "v1.2.3",
    -            "name": "Release 1.2.3",
    -            "html_url": "https://github.com/o/r/releases/tag/v1.2.3",
    -            "author": {"login": "octo"},
    -            "prerelease": true,
    -            "body": "Release notes."
    -        });
    -        assert!(github_commit_body(&commit).contains("commit abcdef1"));
    -        assert!(github_commit_body(&commit).contains("Fix vote page"));
    -        assert!(github_release_body(&release).contains("release Release 1.2.3"));
    -        assert!(github_release_body(&release).contains("Prerelease: yes"));
    -    }
    -}
    diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
    index 9ca66e7c5860d428e95abf5df518fc1e7b4f6332..e2dc6e5529d4a3126723d0dea75931d0738b6c83 100644
    --- a/server/src/html/garden.rs
    +++ b/server/src/html/garden.rs
    @@ -7,7 +7,7 @@ use axum_extra::extract::cookie::CookieJar;
     use maud::html;
     use serde::Deserialize;
     use serde_json::json;
    -use std::collections::HashSet;
    +use std::collections::{HashMap, HashSet};
     
     use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE, Engine as _};
     
    @@ -21,8 +21,8 @@ use crate::{
         path_types::ItemId,
         reducer::{ContentState, ReducerState, ScopeId},
         scope_rank::{
    -        build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive,
    -        suggest_next_pair_in_pool, ChildrenRankings,
    +        build_children_rankings, build_rankings_for_item_set, external_root_host_items,
    +        resolve_scope_recursive, suggest_next_pair_in_pool, ChildrenRankings,
         },
         state::AppState,
         timeago,
    @@ -33,7 +33,7 @@ use super::{
         breadcrumb_path::{ExternalOntologyPath, OntologyPath},
         cli_panel,
         forum::ThreadNav,
    -    layout, layout_full_bleed_chromeless, now_ms, ratio_pct, render_linkified_with_embeds_in_scope,
    +    layout, layout_full_bleed_chromeless, now_ms, ratio_pct, render_item_body_in_scope,
         theme_from_jar, theme_next_from_uri,
     };
     
    @@ -358,6 +358,7 @@ fn vote_compare_item_card(
         item: &ItemId,
         body: Option<&String>,
         side_class: &str,
    +    item_bodies: Option<&HashMap>,
     ) -> maud::Markup {
         html! {
             div class=(format!("vote-compare-side {side_class}")) {
    @@ -366,10 +367,10 @@ fn vote_compare_item_card(
                 }
                 @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {
                     div class="vote-compare-item-body" {
    -                    (render_linkified_with_embeds_in_scope(
    +                    (render_item_body_in_scope(
                             body,
                             nav.garden_root_url(),
    -                        None,
    +                        item_bodies,
                         ))
                     }
                 } @else {
    @@ -678,10 +679,11 @@ pub async fn external_garden_index(
     ) -> impl IntoResponse {
         let nav = ThreadNav::public();
         let ext_path = ExternalOntologyPath::from_input("");
    -    let parent = ItemId::parse("https://.").unwrap();
         let child_rankings = {
             let reduced = state.reduced.read().await;
    -        build_children_rankings(reduced.public(), &parent)
    +        let content = reduced.public();
    +        let hosts = external_root_host_items(content);
    +        build_rankings_for_item_set(content, &hosts)
         };
     
         let url_key = canonical_view_url(&uri);
    @@ -812,9 +814,11 @@ pub async fn room_external_garden_index(
             return room_not_found_page(&jar, &uri).into_response();
         }
         let ext_path = ExternalOntologyPath::from_input("");
    -    let parent = ItemId::parse("https://.").unwrap();
    -    let child_rankings =
    -        build_children_rankings(content_for_garden_view(&reduced, &nav.scope()), &parent);
    +    let child_rankings = {
    +        let content = content_for_garden_view(&reduced, &nav.scope());
    +        let hosts = external_root_host_items(content);
    +        build_rankings_for_item_set(content, &hosts)
    +    };
         drop(reduced);
     
         let url_key = canonical_view_url(&uri);
    @@ -1334,7 +1338,7 @@ async fn render_scope_view(
                     }
                     @if let Some(body) = &model.body {
                         div class="ont-item-content" {
    -                        (render_linkified_with_embeds_in_scope(
    +                        (render_item_body_in_scope(
                                 body,
                                 nav.garden_root_url(),
                                 Some(&scope_content.item_bodies),
    @@ -1592,6 +1596,7 @@ async fn vote_compare_inner(
         let edge_history = vote_edge_history_markup(content, &left, &right);
         let left_body = content.item_bodies.get(&left).cloned();
         let right_body = content.item_bodies.get(&right).cloned();
    +    let item_bodies_for_cards = content.item_bodies.clone();
         let next_pair = suggest_next_vote_pair(content, &left, &right);
         drop(reduced);
     
    @@ -1623,9 +1628,21 @@ async fn vote_compare_inner(
         section class="vote-compare-shell" {
             h2 { "compare" }
             div class="vote-compare-pair" {
    -            (vote_compare_item_card(&nav, &left, left_body.as_ref(), "vote-compare-left"))
    +            (vote_compare_item_card(
    +                &nav,
    +                &left,
    +                left_body.as_ref(),
    +                "vote-compare-left",
    +                Some(&item_bodies_for_cards),
    +            ))
                 span class="vote-compare-vs" { "vs" }
    -            (vote_compare_item_card(&nav, &right, right_body.as_ref(), "vote-compare-right"))
    +            (vote_compare_item_card(
    +                &nav,
    +                &right,
    +                right_body.as_ref(),
    +                "vote-compare-right",
    +                Some(&item_bodies_for_cards),
    +            ))
             }
             (vote_compare_nav_markup(&nav, next_pair.as_ref(), &left, &right, q.thread.as_deref()))
             div id="vote-edge-history-region" {
    @@ -2020,6 +2037,40 @@ mod tests {
             assert!(items.contains("https://slug.social/~/topic/b"));
         }
     
    +    #[test]
    +    fn vote_compare_item_card_renders_github_import_markup() {
    +        use crate::html::forum::ThreadNav;
    +        use super::vote_compare_item_card;
    +        use crate::path_types::ItemId;
    +
    +        let nav = ThreadNav::public();
    +        let item = ItemId::parse("https://github.com/o/r/issues/1").unwrap();
    +        let json = serde_json::json!({
    +            "v": 1,
    +            "schema": "slug_github_import",
    +            "kind": "issue",
    +            "url": "https://github.com/o/r/issues/1",
    +            "headline": "#1 Compare card",
    +            "sublines": ["State: open"],
    +        });
    +        let body = format!("```slug-github-card\n{}\n```", json.to_string());
    +        let html = vote_compare_item_card(
    +            &nav,
    +            &item,
    +            Some(&body),
    +            "vote-compare-left",
    +            None,
    +        )
    +        .into_string();
    +        assert!(
    +            html.contains("github-import-card"),
    +            "expected rich GitHub card markup, got: {html}"
    +        );
    +        assert!(html.contains("item-body-rich"));
    +        assert!(html.contains("vote-compare-left"));
    +        assert!(html.contains("#1 Compare card"));
    +    }
    +
         #[test]
         fn external_source_href_maps_youtube_path_identity_back_to_watch_url() {
             assert_eq!(
    diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
    index a1b929625acbd5298c0cf62f3ca0892079edcb2a..3b23e19a8b35aa4c0b0480e29de7d46ad57ab276 100644
    --- a/server/src/html/mod.rs
    +++ b/server/src/html/mod.rs
    @@ -793,6 +793,20 @@ pub(super) fn render_linkified_with_embeds_in_scope(
         }
     }
     
    +/// Item page / thread body: resolver-specific rich HTML, else linkified `
    ` + media embeds.
    +pub(super) fn render_item_body_in_scope(
    +    raw: &str,
    +    garden_prefix: &str,
    +    item_bodies: Option<&HashMap>,
    +) -> Markup {
    +    if let Some(m) = crate::resolvers::try_render_resolver_item_body(raw) {
    +        return html! {
    +            div class="item-body-rich" { (m) }
    +        };
    +    }
    +    render_linkified_with_embeds_in_scope(raw, garden_prefix, item_bodies)
    +}
    +
     /// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.
     fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {
         assert!(
    diff --git a/server/src/lib.rs b/server/src/lib.rs
    index 84e94bbec144eae77de68482385941cd2c5845eb..c1d477d21aea03aff00e6f0689b0b4379d0d68d2 100644
    --- a/server/src/lib.rs
    +++ b/server/src/lib.rs
    @@ -5,7 +5,7 @@ pub mod canonical_path;
     pub mod dsl;
     pub mod event_log;
     pub mod events;
    -pub mod external_resolver;
    +pub mod resolvers;
     pub mod form_template;
     pub mod html;
     pub mod identity;
    @@ -51,7 +51,7 @@ pub fn create_app_state(cfg: AppConfig) -> AppState {
             write_tx,
             views,
             resolver_runs: Arc::new(RwLock::new(HashMap::new())),
    -        github_resolver: Arc::new(crate::external_resolver::GitHubResolver::from_env()),
    +        github_resolver: Arc::new(crate::resolvers::GitHubResolver::from_env()),
         };
         tokio::spawn(crate::api::write_actor::writer_actor(
             write_rx,
    diff --git a/server/src/resolvers/default_external.rs b/server/src/resolvers/default_external.rs
    new file mode 100644
    index 0000000000000000000000000000000000000000..d37c222abcee3c20b22189b2822da9e9a6ff0515
    --- /dev/null
    +++ b/server/src/resolvers/default_external.rs
    @@ -0,0 +1,22 @@
    +use async_trait::async_trait;
    +
    +use crate::path_types::ItemId;
    +use super::github::ExternalResolver;
    +
    +/// Placeholder until other domain-specific resolvers exist.
    +pub struct DefaultExternalResolver;
    +
    +#[async_trait]
    +impl ExternalResolver for DefaultExternalResolver {
    +    fn domain_match(&self) -> &'static str {
    +        ""
    +    }
    +
    +    fn normalize(&self, path: &str) -> String {
    +        path.to_string()
    +    }
    +
    +    async fn fetch_body(&self, _item: &ItemId) -> Result {
    +        Err("external fetch not implemented".to_string())
    +    }
    +}
    diff --git a/server/src/resolvers/github.rs b/server/src/resolvers/github.rs
    new file mode 100644
    index 0000000000000000000000000000000000000000..5a9c0c38ff01ca5894f7dc62c1008371dacb0cf1
    --- /dev/null
    +++ b/server/src/resolvers/github.rs
    @@ -0,0 +1,755 @@
    +use async_trait::async_trait;
    +use maud::html;
    +use serde::{Deserialize, Serialize};
    +use serde_json::Value;
    +use tokio::sync::oneshot;
    +
    +use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd};
    +
    +pub const SLUG_GITHUB_SCHEMA: &str = "slug_github_import";
    +
    +const GITHUB_SYSTEM_PRINCIPAL: &str = "system:github-resolver";
    +const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000;
    +const GITHUB_MAX_PAGES: usize = 3;
    +
    +fn now_ms() -> i64 {
    +    use std::time::{SystemTime, UNIX_EPOCH};
    +    SystemTime::now()
    +        .duration_since(UNIX_EPOCH)
    +        .unwrap_or_default()
    +        .as_millis() as i64
    +}
    +
    +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
    +#[serde(rename_all = "snake_case")]
    +pub enum GithubImportKind {
    +    Repo,
    +    RepoSection,
    +    Issue,
    +    Pull,
    +    Commit,
    +    Release,
    +}
    +
    +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
    +pub struct GithubImportCard {
    +    pub v: u32,
    +    #[serde(default)]
    +    pub schema: String,
    +    pub kind: GithubImportKind,
    +    pub url: String,
    +    pub headline: String,
    +    #[serde(default)]
    +    pub sublines: Vec,
    +    #[serde(default)]
    +    pub excerpt: Option,
    +}
    +
    +impl GithubImportCard {
    +    fn new(kind: GithubImportKind, url: String, headline: String) -> Self {
    +        Self {
    +            v: 1,
    +            schema: SLUG_GITHUB_SCHEMA.to_string(),
    +            kind,
    +            url,
    +            headline,
    +            sublines: Vec::new(),
    +            excerpt: None,
    +        }
    +    }
    +}
    +
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +pub struct ResolvedChild {
    +    pub url: String,
    +    pub title: String,
    +    pub card: GithubImportCard,
    +}
    +
    +#[async_trait]
    +pub trait ExternalResolver: Send + Sync {
    +    /// e.g. `"github.com"`
    +    fn domain_match(&self) -> &'static str;
    +
    +    /// Normalizes URLs (e.g. stripping fragments); extend per-domain later.
    +    fn normalize(&self, path: &str) -> String;
    +
    +    /// Fetches body when missing; GitHub hook lands here in a follow-up.
    +    async fn fetch_body(&self, item: &ItemId) -> Result;
    +}
    +
    +#[derive(Clone)]
    +pub struct GitHubResolver {
    +    client: reqwest::Client,
    +    api_base_url: String,
    +    token: Option,
    +}
    +
    +impl GitHubResolver {
    +    pub fn from_env() -> Self {
    +        let api_base_url = std::env::var("SLUG_GITHUB_API_BASE_URL")
    +            .ok()
    +            .filter(|s| !s.trim().is_empty())
    +            .unwrap_or_else(|| "https://api.github.com".to_string());
    +        let token = std::env::var("SLUG_GITHUB_TOKEN")
    +            .ok()
    +            .filter(|s| !s.trim().is_empty());
    +        Self {
    +            client: reqwest::Client::new(),
    +            api_base_url: api_base_url.trim_end_matches('/').to_string(),
    +            token,
    +        }
    +    }
    +
    +    pub fn can_resolve_children(&self, item: &ItemId) -> bool {
    +        github_segments(item).is_some()
    +    }
    +
    +    pub async fn list_children(&self, item: &ItemId) -> Result, String> {
    +        let segments = github_segments(item).ok_or_else(|| "not a GitHub URL".to_string())?;
    +        match segments.as_slice() {
    +            [] => Ok(vec![]),
    +            [owner] => self.list_repos(owner).await,
    +            [owner, repo] => Ok(github_repo_sections(owner, repo)),
    +            [owner, repo, section] if section == "issues" => self.list_issues(owner, repo).await,
    +            [owner, repo, section] if section == "pulls" => self.list_pulls(owner, repo).await,
    +            [owner, repo, section] if section == "commits" => self.list_commits(owner, repo).await,
    +            [owner, repo, section] if section == "releases" => {
    +                self.list_releases(owner, repo).await
    +            }
    +            _ => Ok(vec![]),
    +        }
    +    }
    +
    +    async fn get_json(&self, path: &str) -> Result {
    +        let url = format!("{}/{}", self.api_base_url, path.trim_start_matches('/'));
    +        let mut req = self
    +            .client
    +            .get(url)
    +            .header(reqwest::header::USER_AGENT, "slugsocial-github-resolver");
    +        if let Some(token) = &self.token {
    +            req = req.bearer_auth(token);
    +        }
    +        let resp = req
    +            .send()
    +            .await
    +            .map_err(|e| format!("GitHub request failed: {e}"))?;
    +        let status = resp.status();
    +        if !status.is_success() {
    +            return Err(format!("GitHub request returned {status}"));
    +        }
    +        resp.json::()
    +            .await
    +            .map_err(|e| format!("GitHub response JSON failed: {e}"))
    +    }
    +
    +    async fn get_json_array_pages(&self, path: &str) -> Result, String> {
    +        let sep = if path.contains('?') { '&' } else { '?' };
    +        let mut out = Vec::new();
    +        for page in 1..=GITHUB_MAX_PAGES {
    +            let value = self.get_json(&format!("{path}{sep}page={page}")).await?;
    +            let arr = value
    +                .as_array()
    +                .ok_or_else(|| "GitHub paged response was not an array".to_string())?;
    +            let n = arr.len();
    +            out.extend(arr.iter().cloned());
    +            if n < 100 {
    +                break;
    +            }
    +        }
    +        Ok(out)
    +    }
    +
    +    async fn list_repos(&self, owner: &str) -> Result, String> {
    +        let arr = self
    +            .get_json_array_pages(&format!(
    +                "/users/{owner}/repos?per_page=100&sort=updated&type=owner"
    +            ))
    +            .await?;
    +        let mut out = Vec::new();
    +        for repo in &arr {
    +            let name = repo
    +                .get("name")
    +                .and_then(|v| v.as_str())
    +                .unwrap_or_default();
    +            if name.is_empty() {
    +                continue;
    +            }
    +            let full_name = repo
    +                .get("full_name")
    +                .and_then(|v| v.as_str())
    +                .map(|s| s.to_ascii_lowercase())
    +                .unwrap_or_else(|| format!("{owner}/{name}").to_ascii_lowercase());
    +            let url = format!("https://github.com/{full_name}");
    +            let mut card = card_for_repo(repo, &url);
    +            card.headline = full_name.clone();
    +            out.push(ResolvedChild {
    +                url,
    +                title: full_name,
    +                card,
    +            });
    +        }
    +        out.sort_by(|a, b| a.url.cmp(&b.url));
    +        Ok(out)
    +    }
    +
    +    async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> {
    +        let arr = self
    +            .get_json_array_pages(&format!(
    +                "/repos/{owner}/{repo}/issues?state=open&per_page=100"
    +            ))
    +            .await?;
    +        let mut out = Vec::new();
    +        for issue in &arr {
    +            if issue.get("pull_request").is_some() {
    +                continue;
    +            }
    +            let Some(number) = issue.get("number").and_then(|v| v.as_i64()) else {
    +                continue;
    +            };
    +            let title = issue
    +                .get("title")
    +                .and_then(|v| v.as_str())
    +                .unwrap_or("Untitled issue");
    +            let url = format!("https://github.com/{owner}/{repo}/issues/{number}");
    +            let card = card_for_issue(issue, &url, GithubImportKind::Issue);
    +            out.push(ResolvedChild {
    +                url: url.clone(),
    +                title: format!("#{number} {title}"),
    +                card,
    +            });
    +        }
    +        out.sort_by(|a, b| a.url.cmp(&b.url));
    +        Ok(out)
    +    }
    +
    +    async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> {
    +        let arr = self
    +            .get_json_array_pages(&format!(
    +                "/repos/{owner}/{repo}/pulls?state=open&per_page=100"
    +            ))
    +            .await?;
    +        let mut out = Vec::new();
    +        for pull in &arr {
    +            let Some(number) = pull.get("number").and_then(|v| v.as_i64()) else {
    +                continue;
    +            };
    +            let title = pull
    +                .get("title")
    +                .and_then(|v| v.as_str())
    +                .unwrap_or("Untitled pull request");
    +            let url = format!("https://github.com/{owner}/{repo}/pulls/{number}");
    +            let card = card_for_issue(pull, &url, GithubImportKind::Pull);
    +            out.push(ResolvedChild {
    +                url: url.clone(),
    +                title: format!("#{number} {title}"),
    +                card,
    +            });
    +        }
    +        out.sort_by(|a, b| a.url.cmp(&b.url));
    +        Ok(out)
    +    }
    +
    +    async fn list_commits(&self, owner: &str, repo: &str) -> Result, String> {
    +        let arr = self
    +            .get_json_array_pages(&format!("/repos/{owner}/{repo}/commits?per_page=100"))
    +            .await?;
    +        let mut out = Vec::new();
    +        for commit in &arr {
    +            let Some(sha) = github_string(commit, "sha") else {
    +                continue;
    +            };
    +            let short = sha.chars().take(7).collect::();
    +            let title = commit
    +                .get("commit")
    +                .and_then(|c| c.get("message"))
    +                .and_then(|v| v.as_str())
    +                .and_then(|m| m.lines().next())
    +                .filter(|s| !s.trim().is_empty())
    +                .unwrap_or("commit");
    +            let url = github_string(commit, "html_url")
    +                .map(|s| s.to_string())
    +                .unwrap_or_else(|| format!("https://github.com/{owner}/{repo}/commit/{sha}"));
    +            let card = card_for_commit(commit, &url, &short, title);
    +            out.push(ResolvedChild {
    +                url: url.clone(),
    +                title: format!("{short} {title}"),
    +                card,
    +            });
    +        }
    +        out.sort_by(|a, b| a.url.cmp(&b.url));
    +        Ok(out)
    +    }
    +
    +    async fn list_releases(&self, owner: &str, repo: &str) -> Result, String> {
    +        let arr = self
    +            .get_json_array_pages(&format!("/repos/{owner}/{repo}/releases?per_page=100"))
    +            .await?;
    +        let mut out = Vec::new();
    +        for release in &arr {
    +            let Some(tag) = github_string(release, "tag_name") else {
    +                continue;
    +            };
    +            let title = github_string(release, "name").unwrap_or(tag);
    +            let url = github_string(release, "html_url")
    +                .map(|s| s.to_string())
    +                .unwrap_or_else(|| format!("https://github.com/{owner}/{repo}/releases/tag/{tag}"));
    +            let card = card_for_release(release, &url, title);
    +            out.push(ResolvedChild {
    +                url: url.clone(),
    +                title: title.to_string(),
    +                card,
    +            });
    +        }
    +        out.sort_by(|a, b| a.url.cmp(&b.url));
    +        Ok(out)
    +    }
    +}
    +
    +fn github_segments(item: &ItemId) -> Option> {
    +    let url = url::Url::parse(item.as_str()).ok()?;
    +    if url.host_str()?.eq_ignore_ascii_case("github.com") {
    +        Some(
    +            url.path_segments()
    +                .map(|segments| {
    +                    segments
    +                        .filter(|s| !s.is_empty())
    +                        .map(|s| s.to_ascii_lowercase())
    +                        .collect::>()
    +                })
    +                .unwrap_or_default(),
    +        )
    +    } else {
    +        None
    +    }
    +}
    +
    +fn title_case_segment(seg: &str) -> String {
    +    let mut c = seg.chars();
    +    match c.next() {
    +        None => String::new(),
    +        Some(f) => f.to_uppercase().chain(c).collect(),
    +    }
    +}
    +
    +fn github_repo_sections(owner: &str, repo: &str) -> Vec {
    +    [
    +        ("issues", "GitHub issues for this repository."),
    +        ("pulls", "GitHub pull requests for this repository."),
    +        ("commits", "GitHub commits for this repository."),
    +        ("releases", "GitHub releases for this repository."),
    +    ]
    +    .into_iter()
    +    .map(|(section, blurb)| {
    +        let url = format!("https://github.com/{owner}/{repo}/{section}");
    +        let mut card = GithubImportCard::new(
    +            GithubImportKind::RepoSection,
    +            url.clone(),
    +            format!("{owner}/{repo} — {}", title_case_segment(section)),
    +        );
    +        card.excerpt = Some(blurb.to_string());
    +        ResolvedChild {
    +            url,
    +            title: section.to_string(),
    +            card,
    +        }
    +    })
    +    .collect()
    +}
    +
    +fn resolver_thread_tag(item: &ItemId) -> String {
    +    let tail = item
    +        .display_path()
    +        .trim_start_matches("-/")
    +        .replace('/', ":")
    +        .replace('?', ":");
    +    format!("import:{tail}")
    +}
    +
    +fn children_to_dsl(children: &[ResolvedChild]) -> String {
    +    let mut out = String::new();
    +    for child in children {
    +        let json = serde_json::to_string(&child.card).unwrap_or_else(|_| "{}".to_string());
    +        let inner = format!("```slug-github-card\n{json}\n```");
    +        out.push_str(&format!("{} {{\n{}\n}}\n\n", child.url, inner));
    +    }
    +    out
    +}
    +
    +fn card_for_repo(repo: &Value, fallback_url: &str) -> GithubImportCard {
    +    let url = github_string(repo, "html_url")
    +        .map(|s| s.to_string())
    +        .filter(|s| !s.is_empty())
    +        .unwrap_or_else(|| fallback_url.to_string());
    +    let full_name = github_string(repo, "full_name")
    +        .or_else(|| github_string(repo, "name"))
    +        .unwrap_or("repository");
    +    let mut card = GithubImportCard::new(GithubImportKind::Repo, url, full_name.to_string());
    +    if let Some(lang) = github_string(repo, "language") {
    +        card.sublines.push(format!("Language: {lang}"));
    +    }
    +    if let Some(desc) = github_string(repo, "description") {
    +        card.excerpt = Some(desc.to_string());
    +    }
    +    card
    +}
    +
    +fn excerpt_from_github_body(body: Option<&str>) -> Option {
    +    let b = body?.trim();
    +    if b.is_empty() {
    +        return None;
    +    }
    +    let max = 1200usize;
    +    if b.len() <= max {
    +        Some(b.to_string())
    +    } else {
    +        Some(format!("{}…", b.chars().take(max).collect::()))
    +    }
    +}
    +
    +fn card_for_issue(v: &Value, url: &str, kind: GithubImportKind) -> GithubImportCard {
    +    let number = v.get("number").and_then(|n| n.as_i64());
    +    let title = github_string(v, "title").unwrap_or("Untitled");
    +    let state = github_string(v, "state").unwrap_or("unknown");
    +    let headline = match number {
    +        Some(n) => format!("#{n} {title}"),
    +        None => title.to_string(),
    +    };
    +    let mut card = GithubImportCard::new(kind, url.to_string(), headline);
    +    card.sublines.push(format!("State: {state}"));
    +    if let Some(a) = github_user_login(v) {
    +        card.sublines.push(format!("Author: @{a}"));
    +    }
    +    let labels = github_labels(v);
    +    if !labels.is_empty() {
    +        card.sublines
    +            .push(format!("Labels: {}", labels.join(", ")));
    +    }
    +    card.excerpt = excerpt_from_github_body(github_string(v, "body"));
    +    card
    +}
    +
    +fn card_for_commit(v: &Value, url: &str, short_sha: &str, subject: &str) -> GithubImportCard {
    +    let headline = format!("{short_sha} {subject}");
    +    let mut card = GithubImportCard::new(GithubImportKind::Commit, url.to_string(), headline);
    +    if let Some(name) = v
    +        .get("commit")
    +        .and_then(|c| c.get("author"))
    +        .and_then(|a| a.get("name"))
    +        .and_then(|n| n.as_str())
    +        .filter(|s| !s.trim().is_empty())
    +    {
    +        card.sublines.push(format!("Author: {name}"));
    +    }
    +    if let Some(login) = github_user_login(v) {
    +        card.sublines.push(format!("GitHub: @{login}"));
    +    }
    +    if let Some(date) = v
    +        .get("commit")
    +        .and_then(|c| c.get("author"))
    +        .and_then(|a| a.get("date"))
    +        .and_then(|d| d.as_str())
    +    {
    +        card.sublines.push(format!("Date: {date}"));
    +    }
    +    if let Some(msg) = v
    +        .get("commit")
    +        .and_then(|c| c.get("message"))
    +        .and_then(|m| m.as_str())
    +    {
    +        card.excerpt = excerpt_from_github_body(Some(msg));
    +    }
    +    card
    +}
    +
    +fn card_for_release(v: &Value, url: &str, title: &str) -> GithubImportCard {
    +    let tag = github_string(v, "tag_name").unwrap_or("untagged");
    +    let mut card = GithubImportCard::new(
    +        GithubImportKind::Release,
    +        url.to_string(),
    +        format!("Release — {title}"),
    +    );
    +    card.sublines.push(format!("Tag: {tag}"));
    +    if v.get("draft").and_then(|b| b.as_bool()).unwrap_or(false) {
    +        card.sublines.push("Draft: yes".to_string());
    +    }
    +    if v.get("prerelease")
    +        .and_then(|b| b.as_bool())
    +        .unwrap_or(false)
    +    {
    +        card.sublines.push("Prerelease: yes".to_string());
    +    }
    +    if let Some(a) = github_user_login(v) {
    +        card.sublines.push(format!("Author: @{a}"));
    +    }
    +    if let Some(pub_at) = github_string(v, "published_at") {
    +        card.sublines.push(format!("Published: {pub_at}"));
    +    }
    +    card.excerpt = excerpt_from_github_body(github_string(v, "body"));
    +    card
    +}
    +
    +fn github_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
    +    value
    +        .get(key)
    +        .and_then(|v| v.as_str())
    +        .filter(|s| !s.trim().is_empty())
    +}
    +
    +fn github_user_login(value: &Value) -> Option<&str> {
    +    value
    +        .get("user")
    +        .and_then(|u| u.get("login"))
    +        .and_then(|v| v.as_str())
    +        .filter(|s| !s.trim().is_empty())
    +}
    +
    +fn github_labels(value: &Value) -> Vec {
    +    value
    +        .get("labels")
    +        .and_then(|v| v.as_array())
    +        .into_iter()
    +        .flat_map(|labels| labels.iter())
    +        .filter_map(|label| label.get("name").and_then(|v| v.as_str()))
    +        .filter(|name| !name.trim().is_empty())
    +        .map(|name| name.to_string())
    +        .collect()
    +}
    +
    +pub async fn resolve_github_children(
    +    state: &AppState,
    +    room: &str,
    +    item: &ItemId,
    +) -> Result {
    +    if !state.github_resolver.can_resolve_children(item) {
    +        return Err("no GitHub resolver for this item".to_string());
    +    }
    +
    +    let key = format!("github:{}:{}", room.trim(), item.as_str());
    +    let now = now_ms();
    +    {
    +        let mut runs = state.resolver_runs.write().await;
    +        if let Some(last) = runs.get(&key) {
    +            let remaining = GITHUB_RESOLVER_COOLDOWN_MS - (now - *last);
    +            if remaining > 0 {
    +                return Err(format!(
    +                    "GitHub resolver cooldown: try again in {}s",
    +                    (remaining + 999) / 1000
    +                ));
    +            }
    +        }
    +        runs.insert(key, now);
    +    }
    +
    +    let children = state.github_resolver.list_children(item).await?;
    +    if children.is_empty() {
    +        return Ok(0);
    +    }
    +    let text = children_to_dsl(&children);
    +    let thread_tag = resolver_thread_tag(item);
    +    let (tx, rx) = oneshot::channel();
    +    state
    +        .write_tx
    +        .send(WriteCmd::SystemIngest {
    +            room: room.to_string(),
    +            thread_tag,
    +            text,
    +            principal: GITHUB_SYSTEM_PRINCIPAL.to_string(),
    +            reply: tx,
    +        })
    +        .await
    +        .map_err(|_| "writer unavailable".to_string())?;
    +    rx.await
    +        .map_err(|_| "writer dropped".to_string())?
    +        .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!("{msg}: {h}")))?;
    +    Ok(children.len())
    +}
    +
    +fn extract_fence<'a>(body: &'a str, lang: &str) -> Option<&'a str> {
    +    let b = body.trim();
    +    let prefix = format!("```{lang}");
    +    let rest = b.strip_prefix(prefix.as_str())?;
    +    let rest = rest
    +        .strip_prefix('\n')
    +        .or_else(|| rest.strip_prefix('\r'))
    +        .unwrap_or(rest);
    +    let end = rest.find("\n```")?;
    +    Some(rest[..end].trim())
    +}
    +
    +fn parse_github_import_from_body(body: &str) -> Option {
    +    let trimmed = body.trim();
    +    if let Some(json) = extract_fence(trimmed, "slug-github-card") {
    +        let c: GithubImportCard = serde_json::from_str(json).ok()?;
    +        return (c.v == 1 && (c.schema.is_empty() || c.schema == SLUG_GITHUB_SCHEMA)).then_some(c);
    +    }
    +    if let Some(json) = extract_fence(trimmed, "json") {
    +        if let Ok(c) = serde_json::from_str::(json) {
    +            if c.v == 1
    +                && (c.schema == SLUG_GITHUB_SCHEMA
    +                    || (c.schema.is_empty() && c.url.contains("github.com")))
    +            {
    +                return Some(c);
    +            }
    +        }
    +    }
    +    if trimmed.starts_with('{') {
    +        let c: GithubImportCard = serde_json::from_str(trimmed).ok()?;
    +        return (c.v == 1
    +            && (c.schema == SLUG_GITHUB_SCHEMA
    +                || (c.schema.is_empty() && c.url.contains("github.com"))))
    +        .then_some(c);
    +    }
    +    None
    +}
    +
    +fn kind_badge(kind: &GithubImportKind) -> &'static str {
    +    match kind {
    +        GithubImportKind::Repo => "GitHub · repository",
    +        GithubImportKind::RepoSection => "GitHub · tree",
    +        GithubImportKind::Issue => "GitHub · issue",
    +        GithubImportKind::Pull => "GitHub · pull request",
    +        GithubImportKind::Commit => "GitHub · commit",
    +        GithubImportKind::Release => "GitHub · release",
    +    }
    +}
    +
    +fn render_github_card(card: &GithubImportCard) -> maud::Markup {
    +    html! {
    +        article.github-import-card {
    +            header.github-import-card__hdr {
    +                span class="github-import-card__badge" { (kind_badge(&card.kind)) }
    +                h3.github-import-card__title { (card.headline.as_str()) }
    +            }
    +            @if !card.sublines.is_empty() {
    +                ul.github-import-card__meta {
    +                    @for line in &card.sublines {
    +                        li { (line.as_str()) }
    +                    }
    +                }
    +            }
    +            @if let Some(ex) = &card.excerpt {
    +                div.github-import-card__excerpt {
    +                    @for block in ex.split("\n\n") {
    +                        @if !block.trim().is_empty() {
    +                            p { (block) }
    +                        }
    +                    }
    +                }
    +            }
    +            p.github-import-card__link {
    +                a href=(card.url.as_str()) rel="noopener noreferrer" target="_blank" {
    +                    "Open on GitHub"
    +                }
    +            }
    +        }
    +    }
    +}
    +
    +/// Rich HTML for bodies that contain a [`GithubImportCard`] fence (or equivalent JSON).
    +pub fn try_render_github_import_markup(raw: &str) -> Option {
    +    let card = parse_github_import_from_body(raw)?;
    +    Some(render_github_card(&card))
    +}
    +
    +#[async_trait]
    +impl ExternalResolver for GitHubResolver {
    +    fn domain_match(&self) -> &'static str {
    +        "github.com"
    +    }
    +
    +    fn normalize(&self, path: &str) -> String {
    +        path.to_string()
    +    }
    +
    +    async fn fetch_body(&self, _item: &ItemId) -> Result {
    +        Err("GitHub fetch_body not implemented".to_string())
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn github_segments_parse_normalized_url() {
    +        let item = ItemId::parse("https://github.com/Sortersocial/Slug/issues").unwrap();
    +        assert_eq!(
    +            github_segments(&item),
    +            Some(vec![
    +                "sortersocial".to_string(),
    +                "slug".to_string(),
    +                "issues".to_string()
    +            ])
    +        );
    +    }
    +
    +    #[test]
    +    fn repo_sections_are_direct_children() {
    +        let sections = github_repo_sections("sortersocial", "slug");
    +        let urls: Vec = sections.into_iter().map(|c| c.url).collect();
    +        assert!(urls.contains(&"https://github.com/sortersocial/slug/issues".to_string()));
    +        assert!(urls.contains(&"https://github.com/sortersocial/slug/pulls".to_string()));
    +    }
    +
    +    #[test]
    +    fn children_to_dsl_wraps_slug_github_card() {
    +        let dsl = children_to_dsl(&[ResolvedChild {
    +            url: "https://github.com/o/r/issues/1".into(),
    +            title: "#1 title".into(),
    +            card: GithubImportCard::new(
    +                GithubImportKind::Issue,
    +                "https://github.com/o/r/issues/1".into(),
    +                "#1 title".into(),
    +            ),
    +        }]);
    +        assert!(dsl.contains("https://github.com/o/r/issues/1"));
    +        assert!(dsl.contains("```slug-github-card"));
    +        assert!(dsl.contains("\"schema\":\"slug_github_import\""));
    +    }
    +
    +    #[test]
    +    fn parse_accepts_slug_github_fence() {
    +        let card = GithubImportCard::new(
    +            GithubImportKind::Repo,
    +            "https://github.com/o/r".into(),
    +            "o/r".into(),
    +        );
    +        let body = format!("```slug-github-card\n{}\n```\n", serde_json::to_string(&card).unwrap());
    +        let parsed = parse_github_import_from_body(&body).expect("parses");
    +        assert_eq!(parsed, card);
    +    }
    +
    +    #[test]
    +    fn parse_accepts_schema_json_fence() {
    +        let card = GithubImportCard::new(
    +            GithubImportKind::Issue,
    +            "https://github.com/o/r/issues/2".into(),
    +            "#2 hi".into(),
    +        );
    +        let json = serde_json::to_string(&card).unwrap();
    +        let body = format!("```json\n{json}\n```");
    +        let parsed = parse_github_import_from_body(&body).expect("parses json fence");
    +        assert_eq!(parsed.headline, "#2 hi");
    +    }
    +
    +    #[test]
    +    fn issue_card_includes_author_and_excerpt() {
    +        let issue = serde_json::json!({
    +            "number": 12,
    +            "title": "Render children",
    +            "state": "open",
    +            "html_url": "https://github.com/o/r/issues/12",
    +            "user": {"login": "octo"},
    +            "labels": [{"name": "bug"}],
    +            "body": "The issue body."
    +        });
    +        let card = card_for_issue(
    +            &issue,
    +            "https://github.com/o/r/issues/12",
    +            GithubImportKind::Issue,
    +        );
    +        assert!(card.sublines.iter().any(|l| l.contains("@octo")));
    +        assert_eq!(card.excerpt.as_deref(), Some("The issue body.").as_deref());
    +    }
    +}
    diff --git a/server/src/resolvers/mod.rs b/server/src/resolvers/mod.rs
    new file mode 100644
    index 0000000000000000000000000000000000000000..3e4caad081acdd2f89cba9f661de43996d6470f3
    --- /dev/null
    +++ b/server/src/resolvers/mod.rs
    @@ -0,0 +1,18 @@
    +//! Domain resolvers (GitHub, …) and matching HTML renderers for imported item bodies.
    +//!
    +//! Resolver output is ingested as DSL; bodies may embed a `slug-github-card` fenced JSON
    +//! envelope that [`crate::html::render_item_body_in_scope`] renders instead of a raw `
    `.
    +
    +pub mod github;
    +pub mod default_external;
    +
    +pub use default_external::DefaultExternalResolver;
    +pub use github::{
    +    resolve_github_children, try_render_github_import_markup, ExternalResolver, GitHubResolver,
    +    GithubImportCard, GithubImportKind, ResolvedChild,
    +};
    +
    +/// Extension point: add more `try_render_*` calls here as new resolvers ship.
    +pub fn try_render_resolver_item_body(raw: &str) -> Option {
    +    github::try_render_github_import_markup(raw)
    +}
    diff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs
    index 06c560b8eff09b34896d3935d3917fb28f602bc6..2361b2be5ae6b8e1813b6b7ebd5bbf317429b6ad 100644
    --- a/server/src/scope_rank.rs
    +++ b/server/src/scope_rank.rs
    @@ -162,6 +162,45 @@ pub fn build_children_rankings(content: &ContentState, parent: &ItemId) -> Child
         build_rankings_for_item_set(content, &items)
     }
     
    +/// Host-only `https://…` roots for the external garden index (`/-/`).
    +///
    +/// Includes every `https://host` ancestor of any [`ItemId::Web`] item that appears in
    +/// `content.items`, as a parent key in `item_children`, or as a child in `item_children`
    +/// (so implied “ghost” parents created only via [`ReducerState::add_child_edge`] still show up).
    +pub fn external_root_host_items(content: &ContentState) -> Vec {
    +    let mut hosts: HashSet = HashSet::new();
    +
    +    let mut consider = |id: ItemId| {
    +        let id = id.normalized_storage();
    +        if !matches!(&id, ItemId::Web(_)) {
    +            return;
    +        }
    +        let mut cur = id;
    +        while let Some(p) = cur.parent() {
    +            cur = p.normalized_storage();
    +        }
    +        if matches!(cur, ItemId::Web(_)) {
    +            hosts.insert(cur);
    +        }
    +    };
    +
    +    for it in &content.items {
    +        consider(it.clone());
    +    }
    +    for parent in content.item_children.keys() {
    +        consider(parent.clone());
    +    }
    +    for set in content.item_children.values() {
    +        for ch in set {
    +            consider(ch.clone());
    +        }
    +    }
    +
    +    let mut out: Vec = hosts.into_iter().collect();
    +    out.sort();
    +    out
    +}
    +
     pub fn is_pair_voted_in_group(group: &GroupState, a: &ItemId, b: &ItemId) -> bool {
         let Some(&a_idx) = group.item_to_idx.get(a) else {
             return false;
    @@ -305,4 +344,29 @@ mod tests {
             assert!(next.0 == c || next.1 == c);
             assert_ne!(canonical_pair(&next.0, &next.1), canonical_pair(&a, &b));
         }
    +
    +    #[test]
    +    fn external_root_hosts_include_ghost_chain_hosts() {
    +        use crate::reducer::ContentState;
    +        let gh = ItemId::parse("https://github.com").unwrap();
    +        let org = ItemId::parse("https://github.com/org").unwrap();
    +        let repo = ItemId::parse("https://github.com/org/rep").unwrap();
    +        let mut item_children: HashMap> = HashMap::new();
    +        item_children.entry(gh.clone()).or_default().insert(org.clone());
    +        item_children.entry(org.clone()).or_default().insert(repo.clone());
    +        let mut items = HashSet::new();
    +        items.insert(repo.clone());
    +        let content = ContentState {
    +            ranking_group: crate::reducer::GroupState::new(),
    +            items,
    +            item_bodies: HashMap::new(),
    +            item_children,
    +            item_votes: HashMap::new(),
    +            item_snippets: HashMap::new(),
    +            item_threads: HashMap::new(),
    +            rank_history: HashMap::new(),
    +        };
    +        let roots = external_root_host_items(&content);
    +        assert_eq!(roots, vec![gh]);
    +    }
     }
    diff --git a/server/src/state.rs b/server/src/state.rs
    index 48298e2e66456268d23a6462536eb32bfeb5f29b..648ab5304764a329fcabbbbcd3782b94e3e005a8 100644
    --- a/server/src/state.rs
    +++ b/server/src/state.rs
    @@ -4,7 +4,7 @@ use std::sync::Arc;
     use tokio::sync::{broadcast, mpsc, RwLock};
     
     use crate::{
    -    event_log::EventLog, events::ThreadCapability, external_resolver::GitHubResolver,
    +    event_log::EventLog, events::ThreadCapability, resolvers::GitHubResolver,
         reducer::ReducerState, write_cmd::WriteCmd,
     };
     
    diff --git a/server/static/theme_default.css b/server/static/theme_default.css
    index 184e11a590e7019773f7f0abfa41e79161556c71..7ea5f502f9b0b6341ce56d60ae883479070760d6 100644
    --- a/server/static/theme_default.css
    +++ b/server/static/theme_default.css
    @@ -1023,6 +1023,23 @@ body.view-vote-compare .vote-compare-shell > h2 {
       line-height: 1.35;
       padding: 8px 10px;
     }
    +.vote-compare-item-body .item-body-rich {
    +  min-width: 0;
    +  text-align: start;
    +}
    +.vote-compare-right .vote-compare-item-body .item-body-rich {
    +  display: flex;
    +  flex-direction: column;
    +  align-items: flex-end;
    +}
    +.vote-compare-item-body .item-body-rich article.github-import-card {
    +  box-sizing: border-box;
    +  width: 100%;
    +  max-width: min(100%, 420px);
    +}
    +.vote-compare-right .vote-compare-item-body .item-body-rich article.github-import-card {
    +  margin-left: auto;
    +}
     .vote-compare-item-body-empty {
       font-size: 12px;
       margin: 8px 0 0;
    @@ -1675,3 +1692,47 @@ body.view-ontology-light .rank-history-cause {
     body.view-ontology-light .rank-history-vote {
       margin-top: 6px;
     }
    +
    +/* GitHub resolver import cards (rich bodies on -/ garden + vote compare) */
    +article.github-import-card {
    +  border: 1px solid var(--lo);
    +  background: var(--g2);
    +  border-radius: 6px;
    +  padding: 12px 14px;
    +  margin: 8px 0;
    +  max-width: 100%;
    +}
    +.github-import-card__hdr {
    +  margin-bottom: 6px;
    +}
    +.github-import-card__badge {
    +  display: block;
    +  font-size: 0.78em;
    +  color: var(--muted);
    +  margin-bottom: 4px;
    +}
    +.github-import-card__title {
    +  margin: 0;
    +  font-size: 1.05em;
    +  font-weight: 600;
    +}
    +ul.github-import-card__meta {
    +  margin: 8px 0 0 1.1em;
    +  padding: 0;
    +  font-size: 0.9em;
    +}
    +.github-import-card__meta li {
    +  margin: 2px 0;
    +}
    +.github-import-card__excerpt {
    +  margin-top: 10px;
    +  font-size: 0.92em;
    +  white-space: pre-wrap;
    +}
    +.github-import-card__excerpt p {
    +  margin: 6px 0;
    +}
    +.github-import-card__link {
    +  margin-top: 12px;
    +  font-size: 0.95em;
    +}
    diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css
    index 6747f59eb1ec5c335029fe92d4e5c55b3125a210..d366be6fc8e8fcbc8122b8954b1e356ee36e6bc9 100644
    --- a/server/static/theme_retro.css
    +++ b/server/static/theme_retro.css
    @@ -278,3 +278,20 @@ body.view-ontology .vote-compare-item-body pre {
       border: 1px solid #ccc;
       padding: 0.5rem 0.65rem;
     }
    +body.view-ontology .vote-compare-item-body .item-body-rich {
    +  min-width: 0;
    +  text-align: start;
    +}
    +body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich {
    +  display: flex;
    +  flex-direction: column;
    +  align-items: flex-end;
    +}
    +body.view-ontology .vote-compare-item-body .item-body-rich article.github-import-card {
    +  box-sizing: border-box;
    +  width: 100%;
    +  max-width: min(100%, 420px);
    +}
    +body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich article.github-import-card {
    +  margin-left: auto;
    +}
    diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
    index 55d984a6dd70ebeadeca7baef86b844955f1d78c..d5bc384437f05f001924630947457d416772e950 100644
    --- a/server/static/theme_retro_craft.css
    +++ b/server/static/theme_retro_craft.css
    @@ -907,6 +907,23 @@ body.view-ontology .vote-compare-item-body pre {
       line-height: 1.35;
       padding: 0.55rem 0.65rem;
     }
    +body.view-ontology .vote-compare-item-body .item-body-rich {
    +  min-width: 0;
    +  text-align: start;
    +}
    +body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich {
    +  display: flex;
    +  flex-direction: column;
    +  align-items: flex-end;
    +}
    +body.view-ontology .vote-compare-item-body .item-body-rich article.github-import-card {
    +  box-sizing: border-box;
    +  width: 100%;
    +  max-width: min(100%, 420px);
    +}
    +body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich article.github-import-card {
    +  margin-left: auto;
    +}
     body.view-ontology .vote-compare-item-body-empty {
       font-size: 0.78rem;
       margin: 0.45rem 0 0;
    diff --git a/server/tests/integration.rs b/server/tests/integration.rs
    index fb0b9335440181d4d50104d37926b0b2eeeb602a..d979000292b6a39db7c7b54f2b804adb9cd39369 100644
    --- a/server/tests/integration.rs
    +++ b/server/tests/integration.rs
    @@ -3,7 +3,7 @@ use sha2::{Digest, Sha256};
     use slug_types::{room_route_segment, ItemId};
     use slugsocial_server::{
         event_log::EventLog,
    -    events::{Event, TokenIssued, UserRegistered},
    +    events::{Event, Ingest, TokenIssued, UserRegistered},
         middleware::canonical_view_url,
         spawn_writer_actor_for_test,
         state::{AppConfig, AppState},
    @@ -1614,6 +1614,71 @@ async fn test_view_counts_increment_and_display() {
         );
     }
     
    +#[tokio::test]
    +async fn test_vote_compare_renders_github_import_cards() {
    +    let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
    +    let client = reqwest::Client::new();
    +
    +    let raw = "@00000000-0000-0000-0000-000000000000:test:local/test\n\
    +https://github.com/ghvotehi/a/issues/9 {\n\
    +```slug-github-card\n\
    +{\"v\":1,\"schema\":\"slug_github_import\",\"kind\":\"issue\",\"url\":\"https://github.com/ghvotehi/a/issues/9\",\"headline\":\"#9 Left corner\",\"sublines\":[\"State: open\"]}\n\
    +```\n\
    +}\n\
    +\n\
    +https://github.com/ghvotehi/a/issues/10 {\n\
    +```slug-github-card\n\
    +{\"v\":1,\"schema\":\"slug_github_import\",\"kind\":\"issue\",\"url\":\"https://github.com/ghvotehi/a/issues/10\",\"headline\":\"#10 Right corner\",\"sublines\":[\"State: open\"]}\n\
    +```\n\
    +}\n";
    +
    +    {
    +        let mut w = state.reduced.write().await;
    +        w.apply_event(Event::Ingest(Ingest {
    +            ts: 10,
    +            id: "ing-vote-github-cards".to_string(),
    +            raw: raw.to_string(),
    +            principal: "testuser".to_string(),
    +            delegate: Some(
    +                "00000000-0000-0000-0000-000000000000:test:local/test".to_string(),
    +            ),
    +            room_id: "public".to_string(),
    +            thread_tag: "gh-vote-cards".to_string(),
    +        }));
    +    }
    +
    +    let left = ItemId::parse("https://github.com/ghvotehi/a/issues/9")
    +        .unwrap()
    +        .normalized_storage()
    +        .to_storage_string();
    +    let right = ItemId::parse("https://github.com/ghvotehi/a/issues/10")
    +        .unwrap()
    +        .normalized_storage()
    +        .to_storage_string();
    +    let q = format!(
    +        "/vote/compare?left={}&right={}",
    +        urlencoding::encode(&left),
    +        urlencoding::encode(&right)
    +    );
    +    let resp = client
    +        .get(format!("http://{addr}{q}"))
    +        .send()
    +        .await
    +        .unwrap();
    +    assert!(resp.status().is_success(), "{}", resp.status());
    +    let body = resp.text().await.unwrap();
    +    let n_cards = body.matches("github-import-card").count();
    +    assert!(
    +        n_cards >= 2,
    +        "expected two GitHub import cards on vote compare, count={n_cards}, snippet={}",
    +        body.chars().take(1500).collect::()
    +    );
    +    assert!(body.contains("vote-compare-left"));
    +    assert!(body.contains("vote-compare-right"));
    +    assert!(body.contains("#9 Left corner"));
    +    assert!(body.contains("#10 Right corner"));
    +}
    +
     #[tokio::test]
     async fn test_search_handles_multibyte_unicode() {
         // HTML search pages are offline during the auth-v3 refactor.