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 **`
`** 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.