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.
This commit is contained in:
nak0x 2026-09-06 19:50:03 +02:00
parent 6dcb00da80
commit af9723714d
4 changed files with 138 additions and 20 deletions

8
furst-read/src/lib.rs Normal file
View File

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

View File

@ -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

View File

@ -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<String>,
}
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<Node>, e: &Element, cx: &Cx, in_pre: bool) -> Stri
if inner.trim().is_empty() {
return String::new();
}
return format!("<a href=\"{}\">{inner}</a>", escape_attr(&join(cx.base, href)));
return format!("<a href=\"{}\">{inner}</a>", 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<Node>, e: &Element, cx: &Cx, in_pre: bool) -> Stri
format!("<{name}{}>{inner}</{name}>", 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('"', "&quot;")
}
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!("<html><body>{body}</body></html>"));
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#"<p><a href="/x?q=1">t</a></p>"#, "/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#"<p><a href="mailto:a@b.test">t</a></p>"#, "/read?u=");
assert!(out.contains(r#"<a href="mailto:a@b.test">"#));
}
#[test]

View File

@ -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<u8> = 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";