Add furst-serve, a local reader server

Following a link inside an extracted article used to drop the reader back
onto the live site. The server rewrites in-page links to /read?u=... so
browsing stays in reader mode, and caches rendered pages on disk, which
takes a revisit from ~350ms to under a millisecond.

The HTTP layer is blocking and hand-rolled: GET only, one response per
connection, a fixed pool of four worker threads so memory stays
predictable, and nothing but the loopback interface is ever bound.

Routes so far:

  /            home, from ~/.config/furst/home.toml
  /read?u=     the article, with links routed back through the reader
  /go?u=       hand the original URL to the heavy browser

Pages that are not documents redirect to the original rather than being
run through an article extractor, and pages with too little text to be an
article say so and offer the escape hatches. Redirect stubs are followed
before that judgement is made.

furst-serve --open ensures a server is running, starting a detached one if
needed, then execs a browser at the reader URL. Cache entries carry a
schema number so a renderer change drops them rather than serving stale
markup.
This commit is contained in:
nak0x 2026-09-06 19:55:59 +02:00
parent af9723714d
commit 76d0906564
11 changed files with 891 additions and 3 deletions

10
Cargo.lock generated
View File

@ -162,6 +162,16 @@ dependencies = [
"ureq",
]
[[package]]
name = "furst-serve"
version = "0.1.0"
dependencies = [
"furst-read",
"scraper",
"serde",
"toml",
]
[[package]]
name = "futf"
version = "0.1.5"

View File

@ -1,5 +1,5 @@
[workspace]
members = ["furst-read"]
members = ["furst-read", "furst-serve"]
[package]
name = "furst"

View File

@ -10,6 +10,7 @@ pub struct Page {
/// The URL after redirects — the correct base for resolving links.
pub url: String,
pub html: String,
pub content_type: String,
}
pub fn get(url: &str, limit: usize) -> Result<Page, String> {
@ -36,7 +37,7 @@ pub fn get(url: &str, limit: usize) -> Result<Page, String> {
.read_to_vec()
.map_err(|e| format!("{url}: {e}"))?;
Ok(Page { url: final_url, html: decode(&bytes, &content_type) })
Ok(Page { url: final_url, html: decode(&bytes, &content_type), content_type })
}
/// Decode to text using, in order: the Content-Type charset, a `<meta charset>`

View File

@ -78,7 +78,7 @@ fn run() -> Result<(), String> {
let page = if stdin {
let mut buf = Vec::new();
io::stdin().read_to_end(&mut buf).map_err(|e| e.to_string())?;
fetch::Page { url: url.clone(), html: fetch::decode(&buf, "") }
fetch::Page { url: url.clone(), html: fetch::decode(&buf, ""), content_type: String::new() }
} else {
fetch::get(&url, LIMIT)?
};

14
furst-serve/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "furst-serve"
version = "0.1.0"
edition = "2024"
description = "Local reader server: browse the web without a browser engine"
[dependencies]
furst-read = { path = "../furst-read" }
scraper = "0.23"
serde = { version = "1", features = ["derive"] }
toml = { version = "0.8", default-features = false, features = ["parse"] }
[profile.release]
opt-level = "s"

95
furst-serve/src/cache.rs Normal file
View File

@ -0,0 +1,95 @@
//! Rendered pages on disk. Re-reading an article should cost nothing, which on
//! this hardware is the difference between browsing and waiting.
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
/// Bumped whenever the renderer changes, so old entries fall out rather than
/// being served with stale markup.
const SCHEMA: u32 = 1;
pub struct Cache {
dir: Option<PathBuf>,
ttl: Duration,
}
impl Cache {
pub fn new(ttl: Duration) -> Cache {
let dir = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.map(|base| base.join("furst").join("pages"));
if let Some(d) = &dir {
let _ = fs::create_dir_all(d);
}
Cache { dir, ttl }
}
pub fn get(&self, key: &str) -> Option<String> {
let path = self.path(key)?;
let meta = fs::metadata(&path).ok()?;
let age = SystemTime::now().duration_since(meta.modified().ok()?).ok()?;
if age > self.ttl {
let _ = fs::remove_file(&path);
return None;
}
fs::read_to_string(&path).ok()
}
pub fn put(&self, key: &str, value: &str) {
if let Some(path) = self.path(key) {
// Write beside the target and rename, so a reader never sees a
// half-written page.
let tmp = path.with_extension("tmp");
if fs::write(&tmp, value).is_ok() {
let _ = fs::rename(&tmp, &path);
}
}
}
/// Number of entries removed.
pub fn clear(&self) -> usize {
let Some(dir) = &self.dir else { return 0 };
let Ok(entries) = fs::read_dir(dir) else { return 0 };
entries.flatten().filter(|e| fs::remove_file(e.path()).is_ok()).count()
}
fn path(&self, key: &str) -> Option<PathBuf> {
Some(self.dir.as_ref()?.join(format!("{:016x}", fnv1a(&format!("{SCHEMA}\u{0}{key}")))))
}
}
fn fnv1a(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x1000_0000_01b3);
}
h
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_cache(ttl: Duration) -> Cache {
let dir = std::env::temp_dir().join(format!("furst-cache-test-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
Cache { dir: Some(dir), ttl }
}
#[test]
fn round_trips_and_expires() {
let c = temp_cache(Duration::from_secs(60));
c.put("k", "value");
assert_eq!(c.get("k").as_deref(), Some("value"));
assert_eq!(c.get("other"), None);
let expired = temp_cache(Duration::ZERO);
expired.put("k2", "v");
assert_eq!(expired.get("k2"), None, "a zero ttl must never hit");
c.clear();
}
}

66
furst-serve/src/config.rs Normal file
View File

@ -0,0 +1,66 @@
//! The home page's links and feeds, from ~/.config/furst/home.toml.
use std::env;
use std::fs;
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
pub struct Home {
#[serde(default, rename = "link")]
pub links: Vec<Entry>,
#[serde(default, rename = "feed")]
pub feeds: Vec<Entry>,
}
#[derive(Debug, Deserialize)]
pub struct Entry {
pub name: String,
pub url: String,
}
pub fn path() -> PathBuf {
env::var_os("FURST_HOME_CONFIG")
.map(PathBuf::from)
.unwrap_or_else(|| {
env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("furst")
.join("home.toml")
})
}
impl Home {
/// A missing or unreadable file is not an error; the home page simply has
/// nothing on it but the search box.
pub fn load() -> Home {
fs::read_to_string(path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
}
}
pub const STARTER: &str = r#"# furst home page: what shows up at http://127.0.0.1:7714/
[[link]]
name = "Arch Wiki"
url = "https://wiki.archlinux.org/"
[[link]]
name = "Hacker News"
url = "https://news.ycombinator.com/"
[[link]]
name = "Lobsters"
url = "https://lobste.rs/"
[[feed]]
name = "LWN"
url = "https://lwn.net/headlines/newrss"
[[feed]]
name = "Phoronix"
url = "https://www.phoronix.com/rss.php"
"#;

242
furst-serve/src/handler.rs Normal file
View File

@ -0,0 +1,242 @@
//! Route dispatch.
use std::process::{Command, Stdio};
use std::time::Duration;
use furst_read::urljoin::encode;
use furst_read::{extract, fetch, render};
use scraper::Html;
use crate::cache::Cache;
use crate::config::Home;
use crate::http::{Request, Response};
use crate::page;
const LIMIT: usize = 8 * 1024 * 1024;
/// Below this much extracted text a page is a listing, not an article.
pub const THIN: usize = 400;
pub struct App {
pub cache: Cache,
}
impl App {
pub fn new(ttl: Duration) -> App {
App { cache: Cache::new(ttl) }
}
pub fn handle(&self, req: &Request) -> Response {
match req.path.as_str() {
"/" => self.home(),
"/read" => self.read(req),
"/go" => self.go(req),
_ => Response::status(404, page::error("Not found", "No such page.", None)),
}
}
fn home(&self) -> Response {
let home = Home::load();
let mut body = String::from("<h1>furst</h1>");
if home.links.is_empty() && home.feeds.is_empty() {
body.push_str(&page::notice(
"",
&format!(
"Nothing pinned yet. Put links and feeds in <code>{}</code>, \
or search above.",
page::escape(&crate::config::path().display().to_string())
),
));
}
if !home.links.is_empty() {
body.push_str("<p class=\"sec\">Links</p><ul class=\"rows\">");
for l in &home.links {
body.push_str(&format!(
"<li><a class=\"t\" href=\"{}\">{}</a><span class=\"u\">{}</span></li>",
page::read_link(&l.url),
page::escape(&l.name),
page::escape(&host_of(&l.url))
));
}
body.push_str("</ul>");
}
if !home.feeds.is_empty() {
body.push_str("<p class=\"sec\">Feeds</p><ul class=\"rows\">");
for f in &home.feeds {
body.push_str(&format!(
"<li><a class=\"t\" href=\"/feed?u={}\">{}</a><span class=\"u\">{}</span></li>",
encode(&f.url),
page::escape(&f.name),
page::escape(&host_of(&f.url))
));
}
body.push_str("</ul>");
}
Response::html(page::shell("furst", "", "", &body))
}
fn read(&self, req: &Request) -> Response {
let Some(url) = req.param("u").filter(|u| !u.is_empty()) else {
return Response::redirect("/");
};
// Anything we cannot fetch ourselves is the desktop's problem.
if !url.starts_with("http://") && !url.starts_with("https://") {
return Response::redirect(url);
}
let key = format!("read:{url}");
if req.param("fresh").is_none()
&& let Some(hit) = self.cache.get(&key)
{
return Response::html(hit);
}
let page = match fetch::get(url, LIMIT) {
Ok(p) => p,
Err(e) => {
return Response::status(502, page::error("Could not fetch", &e, Some(url)));
}
};
// A PDF or an image is not something to extract an article from.
if !page.content_type.is_empty() && !page.content_type.contains("html") {
return Response::redirect(&page.url);
}
let (doc, base) = follow_refreshes(page);
let article = extract::extract(&doc);
let body = render::body(&doc, &article, &base, &render::Options {
images: true,
link_prefix: Some("/read?u=".to_string()),
});
let html = self.article_page(&article, &base, &body);
self.cache.put(&key, &html);
Response::html(html)
}
fn article_page(&self, article: &extract::Article, base: &str, body: &str) -> String {
let mut meta = Vec::new();
if let Some(b) = &article.byline {
meta.push(page::escape(b));
}
if let Some(s) = &article.site {
meta.push(page::escape(s));
}
meta.push(page::escape(&host_of(base)));
let title = if article.title.is_empty() { host_of(base) } else { article.title.clone() };
let mut content = format!(
"<h1>{}</h1>\n<p class=\"meta\">{}</p>\n",
page::escape(&title),
meta.join(" &middot; ")
);
if text_len(body) < THIN {
content.push_str(&page::notice(
"",
&format!(
"Not much of an article here. Try the <a href=\"{}\">original</a> \
or <a href=\"/go?u={}\">open it in the browser</a>.",
page::escape(base),
encode(base)
),
));
}
content.push_str(body);
page::shell(&title, "", &page::source_actions(base), &content)
}
fn go(&self, req: &Request) -> Response {
let Some(url) = req.param("u").filter(|u| !u.is_empty()) else {
return Response::redirect("/");
};
let browser = std::env::var("FURST_HEAVY_BROWSER").unwrap_or_else(|_| "firefox".to_string());
let spawned = Command::new(&browser)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
let body = match spawned {
Ok(_) => format!(
"<h1>Opened in {}</h1><p><a href=\"{}\">back to the reader</a></p>",
page::escape(&browser),
page::read_link(url)
),
Err(e) => {
return Response::status(
502,
page::error("Could not launch a browser", &format!("{browser}: {e}"), Some(url)),
);
}
};
Response::html(page::shell("Opened", "", "", &body))
}
}
/// Static site generators leave redirect stubs behind; follow them before
/// deciding there is no article here.
fn follow_refreshes(page: fetch::Page) -> (Html, String) {
let mut base = page.url;
let mut doc = Html::parse_document(&page.html);
for _ in 0..3 {
let Some(next) = extract::meta_refresh(&doc, &base) else { break };
if next == base {
break;
}
match fetch::get(&next, LIMIT) {
Ok(p) => {
base = p.url;
doc = Html::parse_document(&p.html);
}
Err(_) => break,
}
}
(doc, base)
}
/// Rough text length of rendered markup, for the "is this actually an article"
/// check. Tag-stripping is enough; this never needs to be exact.
pub fn text_len(html: &str) -> usize {
let mut count = 0;
let mut in_tag = false;
for c in html.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag && !c.is_whitespace() => count += 1,
_ => {}
}
}
count
}
pub fn host_of(url: &str) -> String {
let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let hostport = authority.rsplit('@').next().unwrap_or(authority);
hostport.rsplit_once(':').map(|(h, _)| h).unwrap_or(hostport).trim_start_matches("www.").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_length_ignores_markup() {
assert_eq!(text_len("<p>abc</p>"), 3);
assert_eq!(text_len("<a href=\"very-long-url-here\">x</a>"), 1);
assert!(text_len("<p></p>") < THIN);
}
#[test]
fn host_strips_scheme_port_userinfo_and_www() {
assert_eq!(host_of("https://www.ex.test:8080/a?b"), "ex.test");
assert_eq!(host_of("https://u:p@ex.test/a"), "ex.test");
assert_eq!(host_of("ex.test"), "ex.test");
}
}

196
furst-serve/src/http.rs Normal file
View File

@ -0,0 +1,196 @@
//! A small blocking HTTP/1.1 server. GET only, one response per connection.
//!
//! No async runtime: a fixed pool of worker threads keeps memory predictable,
//! which matters more here than concurrency does. Nothing but the loopback
//! interface is ever bound.
use std::io::{self, BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;
use furst_read::urljoin::decode;
const MAX_REQUEST_LINE: usize = 8 * 1024;
const MAX_HEADERS: usize = 64;
pub struct Request {
pub method: String,
pub path: String,
pub query: Vec<(String, String)>,
}
impl Request {
pub fn param(&self, key: &str) -> Option<&str> {
self.query.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
}
pub struct Response {
pub status: u16,
pub content_type: &'static str,
pub body: Vec<u8>,
pub location: Option<String>,
}
impl Response {
pub fn html(body: String) -> Response {
Response {
status: 200,
content_type: "text/html; charset=utf-8",
body: body.into_bytes(),
location: None,
}
}
pub fn redirect(to: &str) -> Response {
Response {
status: 302,
content_type: "text/plain; charset=utf-8",
body: Vec::new(),
location: Some(to.to_string()),
}
}
pub fn status(status: u16, body: String) -> Response {
Response { status, content_type: "text/html; charset=utf-8", body: body.into_bytes(), location: None }
}
}
pub fn serve<F>(port: u16, workers: usize, handler: F) -> io::Result<()>
where
F: Fn(&Request) -> Response + Send + Sync + 'static,
{
let listener = TcpListener::bind(("127.0.0.1", port))?;
let handler = Arc::new(handler);
let (tx, rx) = mpsc::channel::<TcpStream>();
let rx = Arc::new(Mutex::new(rx));
for _ in 0..workers.max(1) {
let rx = Arc::clone(&rx);
let handler = Arc::clone(&handler);
thread::spawn(move || {
loop {
// The guard is released as soon as recv returns, so workers
// take connections one at a time rather than serialising work.
let stream = rx.lock().unwrap().recv();
match stream {
Ok(s) => handle(s, handler.as_ref()),
Err(_) => break,
}
}
});
}
for stream in listener.incoming().flatten() {
if tx.send(stream).is_err() {
break;
}
}
Ok(())
}
fn handle<F>(mut stream: TcpStream, handler: &F)
where
F: Fn(&Request) -> Response,
{
let _ = stream.set_read_timeout(Some(Duration::from_secs(15)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));
let response = match read_request(&stream) {
Some(req) if req.method == "GET" => handler(&req),
Some(_) => Response::status(405, "<p>only GET is supported</p>".into()),
None => return,
};
let _ = write_response(&mut stream, &response);
}
fn read_request(stream: &TcpStream) -> Option<Request> {
let mut reader = BufReader::new(stream.try_clone().ok()?);
let mut line = String::new();
if reader.read_line(&mut line).ok()? == 0 || line.len() > MAX_REQUEST_LINE {
return None;
}
let mut parts = line.split_whitespace();
let method = parts.next()?.to_string();
let target = parts.next()?.to_string();
// Headers are read only to reach the end of the request; none are used.
for _ in 0..MAX_HEADERS {
let mut header = String::new();
match reader.read_line(&mut header) {
Ok(0) => break,
Ok(_) if header == "\r\n" || header == "\n" => break,
Ok(_) => {}
Err(_) => return None,
}
}
let (path, query) = split_target(&target);
Some(Request { method, path, query })
}
fn split_target(target: &str) -> (String, Vec<(String, String)>) {
let (path, raw) = match target.split_once('?') {
Some((p, q)) => (p, q),
None => (target, ""),
};
let query = raw
.split('&')
.filter(|p| !p.is_empty())
.map(|pair| match pair.split_once('=') {
Some((k, v)) => (decode(k), decode(v)),
None => (decode(pair), String::new()),
})
.collect();
(decode(path), query)
}
fn write_response(stream: &mut TcpStream, r: &Response) -> io::Result<()> {
let mut head = format!("HTTP/1.1 {} {}\r\n", r.status, reason(r.status));
if let Some(location) = &r.location {
head.push_str(&format!("Location: {location}\r\n"));
}
head.push_str(&format!(
"Content-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
r.content_type,
r.body.len()
));
stream.write_all(head.as_bytes())?;
stream.write_all(&r.body)?;
stream.flush()
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
302 => "Found",
400 => "Bad Request",
404 => "Not Found",
405 => "Method Not Allowed",
502 => "Bad Gateway",
_ => "Error",
}
}
#[cfg(test)]
mod tests {
use super::split_target;
#[test]
fn query_values_are_percent_decoded() {
let (path, q) = split_target("/read?u=https%3A%2F%2Fex.test%2Fa%3Fb%3D1&x=2");
assert_eq!(path, "/read");
assert_eq!(q[0], ("u".into(), "https://ex.test/a?b=1".into()));
assert_eq!(q[1], ("x".into(), "2".into()));
}
#[test]
fn a_bare_path_has_no_query() {
let (path, q) = split_target("/");
assert_eq!(path, "/");
assert!(q.is_empty());
}
}

175
furst-serve/src/main.rs Normal file
View File

@ -0,0 +1,175 @@
mod cache;
mod config;
mod handler;
mod http;
mod page;
use std::env;
use std::io::Write;
use std::net::TcpStream;
use std::os::unix::process::CommandExt;
use std::process::{Command, ExitCode, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use furst_read::urljoin::encode;
const USAGE: &str = "\
furst-serve a local reader server: browse the web without a browser engine
USAGE
furst-serve run the server in the foreground
furst-serve --open <url> start it if needed, then open <url> in the reader
furst-serve --url <url> print the reader URL for <url>
furst-serve --status report whether a server is listening
furst-serve --init write a starter home.toml
furst-serve --clear drop the page cache
OPTIONS
--port <n> default 7714, or $FURST_PORT
--ttl <secs> page cache lifetime, default 3600
--browser <cmd> browser to open with
Pages are cached under $XDG_CACHE_HOME/furst/pages. The heavy browser used
by the 'browser' link is $FURST_HEAVY_BROWSER, default firefox.
";
const DEFAULT_PORT: u16 = 7714;
const BROWSERS: &[&str] = &["surf", "luakit", "vimb", "badwolf", "epiphany", "firefox"];
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("furst-serve: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), String> {
let args: Vec<String> = env::args().skip(1).collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
print!("{USAGE}");
return Ok(());
}
let mut port = env::var("FURST_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(DEFAULT_PORT);
let mut ttl = Duration::from_secs(3600);
let mut open: Option<String> = None;
let mut print_url: Option<String> = None;
let mut browser: Option<String> = None;
let mut status = false;
let mut init = false;
let mut clear = false;
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--open" => open = Some(it.next().ok_or("--open needs a url")?),
"--url" => print_url = Some(it.next().ok_or("--url needs a url")?),
"--browser" => browser = Some(it.next().ok_or("--browser needs a command")?),
"--port" => {
port = it.next().ok_or("--port needs a number")?.parse().map_err(|_| "bad port")?
}
"--ttl" => {
let secs: u64 = it.next().ok_or("--ttl needs seconds")?.parse().map_err(|_| "bad ttl")?;
ttl = Duration::from_secs(secs);
}
"--status" => status = true,
"--init" => init = true,
"--clear" => clear = true,
a => return Err(format!("unknown argument {a} (--help for usage)")),
}
}
if init {
return write_starter();
}
if clear {
let n = cache::Cache::new(ttl).clear();
println!("removed {n} cached pages");
return Ok(());
}
if status {
println!(
"{}",
if listening(port) { format!("listening on 127.0.0.1:{port}") } else { "not running".into() }
);
return Ok(());
}
if let Some(url) = print_url {
println!("{}", reader_url(port, &url));
return Ok(());
}
if let Some(url) = open {
return open_in_reader(port, &url, browser);
}
eprintln!("furst-serve: listening on http://127.0.0.1:{port}");
let app = handler::App::new(ttl);
http::serve(port, 4, move |req| app.handle(req)).map_err(|e| format!("port {port}: {e}"))
}
fn reader_url(port: u16, url: &str) -> String {
format!("http://127.0.0.1:{port}/read?u={}", encode(url))
}
fn listening(port: u16) -> bool {
TcpStream::connect_timeout(
&format!("127.0.0.1:{port}").parse().expect("loopback address"),
Duration::from_millis(300),
)
.is_ok()
}
/// Start the server if nothing is on the port, then hand the reader URL to a
/// browser. The server is detached, so it outlives this process.
fn open_in_reader(port: u16, url: &str, browser: Option<String>) -> Result<(), String> {
if !listening(port) {
let exe = env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
Command::new(exe)
.arg("--port")
.arg(port.to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("could not start the server: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline && !listening(port) {
thread::sleep(Duration::from_millis(50));
}
if !listening(port) {
return Err(format!("server did not come up on port {port}"));
}
}
let cmd = browser
.or_else(|| env::var("FURST_BROWSER").ok().filter(|s| !s.is_empty()))
.or_else(|| env::var("BROWSER").ok().filter(|s| !s.is_empty()))
.or_else(|| BROWSERS.iter().find(|b| which(b)).map(|b| b.to_string()))
.ok_or("no browser found; set $FURST_BROWSER")?;
let err = Command::new(&cmd).arg(reader_url(port, url)).exec();
Err(format!("{cmd}: {err}"))
}
fn write_starter() -> Result<(), String> {
let path = config::path();
if path.exists() {
return Err(format!("{} already exists", path.display()));
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
std::fs::write(&path, config::STARTER).map_err(|e| format!("{}: {e}", path.display()))?;
let mut out = std::io::stdout();
let _ = writeln!(out, "wrote {}", path.display());
Ok(())
}
fn which(cmd: &str) -> bool {
env::var_os("PATH").is_some_and(|p| env::split_paths(&p).any(|d| d.join(cmd).is_file()))
}

89
furst-serve/src/page.rs Normal file
View File

@ -0,0 +1,89 @@
//! Page chrome. Everything the server emits shares one shell: a bar with the
//! search box and the escape hatches, then the content.
use furst_read::render::CSS;
use furst_read::urljoin::encode;
pub fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// The reader's own URL for an outside page.
pub fn read_link(url: &str) -> String {
format!("/read?u={}", encode(url))
}
pub fn shell(title: &str, query: &str, actions: &str, body: &str) -> String {
format!(
"<!doctype html>\n<html lang=\"\"><head>\n<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n\
<title>{}</title>\n<style>{CSS}{EXTRA}</style>\n</head>\n<body>\n\
<header class=\"bar\">\
<a class=\"brand\" href=\"/\">furst</a>\
<form class=\"find\" action=\"/search\"><input name=\"q\" value=\"{}\" placeholder=\"search\" autocomplete=\"off\"></form>\
<span class=\"acts\">{actions}</span>\
</header>\n<main>\n{body}</main>\n</body></html>\n",
escape(title),
escape(query)
)
}
/// The bar's right-hand side for a page that came from somewhere else.
pub fn source_actions(url: &str) -> String {
format!(
"<a href=\"/read?u={u}&amp;fresh=1\">refresh</a>\
<a href=\"{orig}\">original</a>\
<a href=\"/go?u={u}\">browser</a>",
u = encode(url),
orig = escape(url)
)
}
pub fn notice(kind: &str, body: &str) -> String {
format!("<p class=\"notice {kind}\">{body}</p>")
}
pub fn error(title: &str, detail: &str, url: Option<&str>) -> String {
let mut body = format!("<h1>{}</h1>{}", escape(title), notice("bad", &escape(detail)));
if let Some(u) = url {
body.push_str(&format!(
"<p><a href=\"{0}\">open {0} directly</a> &middot; <a href=\"/go?u={1}\">open in the browser</a></p>",
escape(u),
encode(u)
));
}
shell(title, "", "", &body)
}
const EXTRA: &str = "\
body{padding:0 0 5rem}\
main{max-width:38rem;margin:0 auto;padding:1.75rem 1.25rem 0}\
.bar{position:sticky;top:0;z-index:9;display:flex;gap:.85rem;align-items:center;\
padding:.55rem 1rem;background:#f2f1ed;border-bottom:1px solid #e0dfda;font-size:.85rem}\
.bar a{color:#555;text-decoration:none;white-space:nowrap}\
.bar a:hover{text-decoration:underline}\
.brand{font-weight:700;color:#111 !important;letter-spacing:.02em}\
.find{flex:1;margin:0}\
.find input{width:100%;padding:.34rem .6rem;font:inherit;color:inherit;\
background:#fff;border:1px solid #d8d7d2;border-radius:3px}\
.acts{display:flex;gap:.75rem}\
.notice{padding:.7rem .9rem;border-radius:3px;background:#f4f3ee;border:1px solid #e2e1db;font-size:.9rem}\
.notice.bad{background:#fdf1ef;border-color:#f0d5d0}\
.rows{list-style:none;margin:0;padding:0}\
.rows li{margin:0 0 1.15rem;padding:0}\
.rows .t{font-size:1.02rem;line-height:1.35;display:block}\
.rows .u{font-size:.8rem;color:#8a8780;display:block;margin-top:.15rem}\
.rows .s{font-size:.88rem;color:#5d5a55;display:block;margin-top:.3rem;line-height:1.5}\
.sec{font-size:.75rem;text-transform:uppercase;letter-spacing:.09em;color:#8a8780;\
margin:2.25rem 0 .9rem;font-weight:600}\
.cmt{margin:0 0 .9rem;padding:.55rem 0 0;border-top:1px solid #eceae4;font-size:.93rem}\
.cmt .h{font-size:.78rem;color:#8a8780;margin-bottom:.3rem}\
.cmt p{margin:0 0 .6rem}\
@media(prefers-color-scheme:dark){\
.bar{background:#1b1b1b;border-color:#2b2b2b}\
.bar a{color:#a9a6a0}.brand{color:#e8e6e1 !important}\
.find input{background:#242424;border-color:#333;color:#d8d6d1}\
.notice{background:#1d1d1d;border-color:#2f2f2f}\
.notice.bad{background:#2a1c1a;border-color:#4a2f2a}\
.rows .u{color:#7c7973}.rows .s{color:#a9a6a0}.sec{color:#7c7973}\
.cmt{border-color:#262626}.cmt .h{color:#7c7973}}";