From af9723714d6c9a6972e4769a36a6eac7ca441c27 Mon Sep 17 00:00:00 2001 From: nak0x Date: Sun, 6 Sep 2026 19:50:03 +0200 Subject: [PATCH] Make furst-read a library and teach the renderer link rewriting furst-serve needs the fetch, extract and render stages, so they move behind a lib target with the CLI as a thin consumer. Adds Options::link_prefix. When set, http and https links are rewritten to {prefix}{percent-encoded url} so that following one stays inside the reader rather than dropping the viewer back onto the live site; other schemes are left alone. render::body exposes the article markup without the standalone document wrapper, for callers supplying their own page chrome, and CSS becomes public so they can reuse it. Percent encode and decode live in urljoin, next to the other URL handling. --- furst-read/src/lib.rs | 8 +++++ furst-read/src/main.rs | 13 +++---- furst-read/src/render.rs | 76 +++++++++++++++++++++++++++++++++------ furst-read/src/urljoin.rs | 61 ++++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 20 deletions(-) create mode 100644 furst-read/src/lib.rs diff --git a/furst-read/src/lib.rs b/furst-read/src/lib.rs new file mode 100644 index 0000000..d92575d --- /dev/null +++ b/furst-read/src/lib.rs @@ -0,0 +1,8 @@ +//! Strip a web page down to its article. +//! +//! Used as a library by `furst-serve` and as a CLI by the `furst-read` binary. + +pub mod extract; +pub mod fetch; +pub mod render; +pub mod urljoin; diff --git a/furst-read/src/main.rs b/furst-read/src/main.rs index 5d4fbbe..58f18a9 100644 --- a/furst-read/src/main.rs +++ b/furst-read/src/main.rs @@ -1,8 +1,3 @@ -mod extract; -mod fetch; -mod render; -mod urljoin; - use std::env; use std::fs; use std::io::{self, Read, Write}; @@ -10,6 +5,8 @@ use std::os::unix::process::CommandExt; use std::path::PathBuf; use std::process::{Command, ExitCode}; +use furst_read::{extract, fetch, render}; + const USAGE: &str = "\ furst-read — strip a page down to the article and render it as minimal HTML @@ -106,7 +103,7 @@ fn run() -> Result<(), String> { let body = if as_text { render::text(&doc, &article) } else { - render::html(&doc, &article, &page.url, &render::Options { images }) + render::html(&doc, &article, &page.url, &render::Options { images, link_prefix: None }) }; // A closed pipe is not an error: `furst-read --text url | head`. if let Err(e) = io::stdout().write_all(body.as_bytes()) @@ -116,12 +113,12 @@ fn run() -> Result<(), String> { } } Sink::File(path) => { - let body = render::html(&doc, &article, &page.url, &render::Options { images }); + let body = render::html(&doc, &article, &page.url, &render::Options { images, link_prefix: None }); fs::write(&path, body).map_err(|e| format!("{}: {e}", path.display()))?; println!("{}", path.display()); } Sink::Open => { - let body = render::html(&doc, &article, &page.url, &render::Options { images }); + let body = render::html(&doc, &article, &page.url, &render::Options { images, link_prefix: None }); let path = cache_path(&page.url)?; fs::write(&path, body).map_err(|e| format!("{}: {e}", path.display()))?; let cmd = browser diff --git a/furst-read/src/render.rs b/furst-read/src/render.rs index 4e3a330..05d08fa 100644 --- a/furst-read/src/render.rs +++ b/furst-read/src/render.rs @@ -9,7 +9,7 @@ use scraper::node::Element; use scraper::{Html, Node}; use crate::extract::Article; -use crate::urljoin::join; +use crate::urljoin::{encode, join}; /// Rendered verbatim, with attributes filtered. const KEEP: &[&str] = &[ @@ -26,21 +26,38 @@ const VOID: &[&str] = &["br", "hr"]; pub struct Options { pub images: bool, + /// When set, http(s) links are rewritten to `{prefix}{percent-encoded url}` + /// so that following one stays inside the reader instead of dropping the + /// viewer back onto the live site. + pub link_prefix: Option, +} + +impl Default for Options { + fn default() -> Self { + Options { images: true, link_prefix: None } + } } struct Cx<'a> { base: &'a str, images: bool, + link_prefix: Option<&'a str>, } +/// The article markup on its own, for callers supplying their own page chrome. +pub fn body(doc: &Html, article: &Article, base: &str, opts: &Options) -> String { + let cx = Cx { base, images: opts.images, link_prefix: opts.link_prefix.as_deref() }; + article + .nodes + .iter() + .filter_map(|id| doc.tree.get(*id)) + .map(|node| node_html(node, &cx, false)) + .collect() +} + +/// A complete standalone document. pub fn html(doc: &Html, article: &Article, base: &str, opts: &Options) -> String { - let cx = Cx { base, images: opts.images }; - let mut body = String::new(); - for id in &article.nodes { - if let Some(node) = doc.tree.get(*id) { - body.push_str(&node_html(node, &cx, false)); - } - } + let body = body(doc, article, base, opts); let mut meta = Vec::new(); if let Some(b) = &article.byline { @@ -125,7 +142,7 @@ fn element_html(node: NodeRef, e: &Element, cx: &Cx, in_pre: bool) -> Stri if inner.trim().is_empty() { return String::new(); } - return format!("{inner}", escape_attr(&join(cx.base, href))); + return format!("{inner}", escape_attr(&cx.link(href))); } // Anything not on the list contributes its children but no tag of its own, @@ -139,6 +156,20 @@ fn element_html(node: NodeRef, e: &Element, cx: &Cx, in_pre: bool) -> Stri format!("<{name}{}>{inner}", attrs_html(e, name)) } +impl Cx<'_> { + /// Resolve a link against the base, then route it back through the reader + /// when a prefix is set. Non-http schemes are always left alone. + fn link(&self, href: &str) -> String { + let abs = join(self.base, href); + match self.link_prefix { + Some(prefix) if abs.starts_with("http://") || abs.starts_with("https://") => { + format!("{prefix}{}", encode(&abs)) + } + _ => abs, + } + } +} + fn attrs_html(e: &Element, name: &str) -> String { let mut out = String::new(); if matches!(name, "td" | "th") { @@ -241,7 +272,7 @@ fn escape_attr(s: &str) -> String { escape_text(s).replace('"', """) } -const CSS: &str = "\ +pub const CSS: &str = "\ :root{color-scheme:light dark}\ *{box-sizing:border-box}\ body{margin:0;padding:2.5rem 1.25rem 6rem;color:#1a1a1a;background:#fdfdfc;\ @@ -288,7 +319,30 @@ mod tests { site: None, nodes: vec![root], }; - html(&doc, &article, "https://ex.test/a/b.html", &Options { images: true }) + html(&doc, &article, "https://ex.test/a/b.html", &Options { images: true, link_prefix: None }) + } + + fn render_with(body: &str, prefix: &str) -> String { + let doc = Html::parse_document(&format!("{body}")); + let root = doc.select(&scraper::Selector::parse("body").unwrap()).next().unwrap().id(); + let article = + extract::Article { title: "T".into(), byline: None, site: None, nodes: vec![root] }; + html(&doc, &article, "https://ex.test/a/b.html", &Options { + images: true, + link_prefix: Some(prefix.to_string()), + }) + } + + #[test] + fn a_link_prefix_keeps_following_links_inside_the_reader() { + let out = render_with(r#"

t

"#, "/read?u="); + assert!(out.contains("/read?u=https%3A%2F%2Fex.test%2Fx%3Fq%3D1")); + } + + #[test] + fn a_link_prefix_leaves_other_schemes_alone() { + let out = render_with(r#"

t

"#, "/read?u="); + assert!(out.contains(r#""#)); } #[test] diff --git a/furst-read/src/urljoin.rs b/furst-read/src/urljoin.rs index ce1f25c..7643ebb 100644 --- a/furst-read/src/urljoin.rs +++ b/furst-read/src/urljoin.rs @@ -95,9 +95,68 @@ fn remove_dot_segments(path: &str) -> String { s } +/// Percent-encode for use as a query parameter value. +pub fn encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +/// Decode a percent-encoded value. Invalid escapes are left as written. +pub fn decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'%' if i + 2 < bytes.len() => { + match u8::from_str_radix(&s[i + 1..i + 3], 16) { + Ok(b) => { + out.push(b); + i += 3; + } + Err(_) => { + out.push(b'%'); + i += 1; + } + } + } + b'+' => { + out.push(b' '); + i += 1; + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + #[cfg(test)] mod tests { - use super::join; + use super::{decode, encode, join}; + + #[test] + fn encode_decode_round_trips() { + let u = "https://ex.test/a b?q=1&r=2#f"; + assert_eq!(decode(&encode(u)), u); + assert!(!encode(u).contains(' ')); + } + + #[test] + fn decode_tolerates_broken_escapes() { + assert_eq!(decode("100%"), "100%"); + assert_eq!(decode("a%zzb"), "a%zzb"); + assert_eq!(decode("a+b"), "a b"); + } + const B: &str = "https://ex.test/a/b/page.html?x=1";