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";