Web

HTML rewriting

HTML rewritten as it streams, by CSS selector: links made absolute, attributes set or removed, elements removed or unwrapped, text replaced.

Version 1.0.0 · 5 functions · 2 crates

#html#links#feeds#performance#security

Functions

What use html_rewrite.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use html_absolute_links, html_set_attribute from html_rewrite (Quillo 0.6).

html_set_attribute(html: str, selector: str, name: str, value: str, only_if_missing: bool) → stror an error

Sets attribute name to value on every element matching selector (img, a[target=_blank], table.data): loading="lazy" on images, rel="noopener" on new-tab links. only_if_missing keeps a value already there.

html_remove(html: str, selector: str, keep_content: bool) → stror an error

Removes every element matching selector (script, iframe, .ad, [onclick]): with all it holds, or, when keep_content, only its tags (unwrapping a <font> or a <span class=x>).

html_rewrite(html: str, rules: list) → stror an error

Several changes in one pass: rules are [{ selector, set: { attr: value }, remove_attributes: [...], remove: true, unwrap: true, text: "...", html: "...", append_html: "..." }], applied in order to the elements each selector matches (text replaces the content with escaped text, html with markup, append_html adds at the end).

Get it

$ quillo install html_rewrite               # html_rewrite.qwer into this folder$ quillo install html_rewrite --example     # and its example, html_rewrite.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/html_rewrite.qwer$ curl -O https://quillolang.org/plugins/files/html_rewrite.qwe

Then, in your program, all of it or only what you call:

main.qwe
use html_rewrite.qweruse html_absolute_links, html_set_attribute from html_rewrite      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
lol_html = "1.2"url = "2"

Example

html_rewrite.qwe
// A blog post's HTML prepared for its RSS feed and for the page: links made absolute, images// lazy, outside links marked, scripts and trackers removed.////   quillo run html_rewrite.qwe      then open http://localhost:8080/feed-htmluse "html_rewrite.qwer"let POST = `<article><p>Read <a href="/docs/routes">the routes guide</a> or <a href="https://github.com/quillolang">the code</a>.</p><img src="../img/cover.jpg" srcset="../img/cover.jpg 1x, ../img/cover@2x.jpg 2x" alt="Cover"><script src="/js/tracker.js"></script><p class="ad">Buy now!</p><p>Questions? <a href="#comments">Comment</a> or <a href="mailto:ada@example.com">mail me</a>.</p></article>`route GET /feed-html {    let clean = html_remove(html_remove(POST, "script", false)?, ".ad", false)?    respond(200, html_absolute_links(clean, "https://blog.example.com/posts/2026/hello")?, { "content-type": "text/plain; charset=utf-8" })}route GET /page = respond(200, html_rewrite(html_external_links(POST, "blog.example.com")?, [    { selector: "img", set: { loading: "lazy", decoding: "async" } },    { selector: "script, .ad", remove: true },    { selector: "article", append_html: "<footer>Thanks for reading</footer>" },])?, { "content-type": "text/plain; charset=utf-8" })route GET /bad = { error: html_remove(POST, "p:nth-child(2n+1 x", false).error }
$ quillo run html_rewrite.qwe$ curl 'localhost:8080/feed-html'$ curl 'localhost:8080/page'$ curl 'localhost:8080/bad'

Source

html_rewrite.qwer
//! HTML rewritten as it streams, with CSS selectors (Cloudflare's lol-html): links and images made absolute, attributes set or removed (loading="lazy", rel="noopener"), elements removed or unwrapped, text replaced. Fast on big pages, keeps the rest of the markup as it was.//! version: 1.0.0//!//! [dependencies]//! lol_html = "1.2"//! url = "2"use lol_html::html_content::{ContentType, Element};use lol_html::{ElementContentHandlers, HandlerResult, RewriteStrSettings, Selector};use serde_json::Value;use std::borrow::Cow;const MAX_HTML: usize = 32 << 20;fn sel(s: &str) -> Result<Selector, String> {    s.parse::<Selector>().map_err(|e| format!("`{}` is not a selector lol-html takes: {}", s, e))}type Handler<'h> = (Cow<'h, Selector>, ElementContentHandlers<'h>);fn on<'h>(selector: &str, f: impl FnMut(&mut Element) -> HandlerResult + 'h) -> Result<Handler<'h>, String> {    Ok((Cow::Owned(sel(selector)?), ElementContentHandlers::default().element(f)))}fn run(html: &str, handlers: Vec<Handler<'_>>) -> Result<String, String> {    if html.len() > MAX_HTML {        return Err("the HTML is at most 32 MB".into());    }    lol_html::rewrite_str(html, RewriteStrSettings { element_content_handlers: handlers, ..RewriteStrSettings::default() }).map_err(|e| e.to_string())}fn absolute(base: &url::Url, v: &str) -> String {    let t = v.trim();    if t.is_empty() || t.starts_with('#') || t.starts_with("data:") || t.starts_with("mailto:") || t.starts_with("tel:") || t.starts_with("javascript:") {        return v.to_string();    }    base.join(t).map(|u| u.to_string()).unwrap_or_else(|_| v.to_string())}/// Every link and source made absolute against `base_url` (the page's address): `href` of `a`, `link` and `area`, `src` of `img`, `script`, `iframe`, `source`, `video`, `audio`, `track`,/// `srcset` entries, `poster` and form `action`s. For feeds, emails and pages shown elsewhere. Anchors (`#top`), `data:` and `mailto:` are left alone.pub fn html_absolute_links(html: &str, base_url: &str) -> Result<String, String> {    let base = url::Url::parse(base_url).map_err(|e| format!("`{}` is not a URL: {}", base_url, e))?;    // Each handler owns its copy of the base URL.    let attr = |name: &'static str| {        let b = base.clone();        move |el: &mut Element| -> HandlerResult {            if let Some(v) = el.get_attribute(name) {                el.set_attribute(name, &absolute(&b, &v))?;            }            Ok(())        }    };    let b = base.clone();    let srcset = move |el: &mut Element| -> HandlerResult {        if let Some(v) = el.get_attribute("srcset") {            let parts: Vec<String> = v                .split(',')                .map(|p| {                    let p = p.trim();                    match p.split_once(char::is_whitespace) {                        Some((u, d)) => format!("{} {}", absolute(&b, u), d.trim()),                        None => absolute(&b, p),                    }                })                .collect();            el.set_attribute("srcset", &parts.join(", "))?;        }        Ok(())    };    run(html, vec![        on("a[href], link[href], area[href]", attr("href"))?,        on("img[src], script[src], iframe[src], source[src], video[src], audio[src], track[src], embed[src]", attr("src"))?,        on("img[srcset], source[srcset]", srcset)?,        on("video[poster]", attr("poster"))?,        on("form[action]", attr("action"))?,    ])}/// Sets attribute `name` to `value` on every element matching `selector` (`img`, `a[target=_blank]`, `table.data`): `loading="lazy"` on images, `rel="noopener"` on new-tab links./// `only_if_missing` keeps a value already there.pub fn html_set_attribute(html: &str, selector: &str, name: &str, value: &str, only_if_missing: bool) -> Result<String, String> {    run(html, vec![on(selector, |el: &mut Element| {        if !(only_if_missing && el.has_attribute(name)) {            el.set_attribute(name, value)?;        }        Ok(())    })?])}/// Removes every element matching `selector` (`script`, `iframe`, `.ad`, `[onclick]`): with all it holds, or, when `keep_content`, only its tags (unwrapping a `<font>` or a `<span class=x>`).pub fn html_remove(html: &str, selector: &str, keep_content: bool) -> Result<String, String> {    run(html, vec![on(selector, |el: &mut Element| {        if keep_content { el.remove_and_keep_content() } else { el.remove() }        Ok(())    })?])}/// Several changes in one pass: `rules` are `[{ selector, set: { attr: value }, remove_attributes: [...], remove: true, unwrap: true, text: "...", html: "...", append_html: "..." }]`,/// applied in order to the elements each selector matches (`text` replaces the content with escaped text, `html` with markup, `append_html` adds at the end).pub fn html_rewrite(html: &str, rules: Vec<serde_json::Value>) -> Result<String, String> {    if rules.len() > 100 {        return Err("at most 100 rules".into());    }    let mut hs = Vec::new();    for r in &rules {        let selector = r["selector"].as_str().ok_or("each rule has a `selector`")?;        let r = r.clone();        hs.push(on(selector, move |el: &mut Element| {            if let Some(set) = r["set"].as_object() {                for (k, v) in set {                    let v = match v {                        Value::String(s) => s.clone(),                        Value::Null => continue,                        other => other.to_string(),                    };                    el.set_attribute(k, &v)?;                }            }            if let Some(rm) = r["remove_attributes"].as_array() {                for a in rm.iter().filter_map(Value::as_str) {                    el.remove_attribute(a);                }            }            if let Some(t) = r["text"].as_str() {                el.set_inner_content(t, ContentType::Text);            }            if let Some(h) = r["html"].as_str() {                el.set_inner_content(h, ContentType::Html);            }            if let Some(h) = r["append_html"].as_str() {                el.append(h, ContentType::Html);            }            if r["remove"] == true {                el.remove();            } else if r["unwrap"] == true {                el.remove_and_keep_content();            }            Ok(())        })?);    }    run(html, hs)}/// Links to other sites marked as such: `rel="nofollow noopener"` and `target="_blank"` on every `a[href]` whose host is not `own_host` (or one of its subdomains). For user content and outbound tracking.pub fn html_external_links(html: &str, own_host: &str) -> Result<String, String> {    let own = own_host.trim().trim_start_matches("www.").to_lowercase();    run(html, vec![on("a[href]", move |el: &mut Element| {        let href = el.get_attribute("href").unwrap_or_default();        if let Ok(u) = url::Url::parse(href.trim()) {            let host = u.host_str().unwrap_or("").trim_start_matches("www.").to_lowercase();            let external = (u.scheme() == "http" || u.scheme() == "https") && host != own && !host.ends_with(&format!(".{}", own));            if external {                el.set_attribute("rel", "nofollow noopener")?;                el.set_attribute("target", "_blank")?;            }        }        Ok(())    })?])}
Link finder link_extract.qwer · 1.0.0URLs and emails found in plain text, with trailing punctuation handled, and the text turned into safe HTML with clickable, nofollow links.textlinkscommentsemailhtml7 views · 6 downloadsMinify HTML minify_html.qwer · 1.0.0HTML made smaller for sending: spaces, comments and optional tags out, and the CSS and JavaScript inside minified too.htmlcssjavascriptperformanceexport5 views · 9 downloadsContent-Security-Policy csp.qwer · 1.0.0Content-Security-Policy headers built from a map and read back, a nonce per page, the policy graded for the holes that let scripts in, and whether a URL would load under it.headerssecurityxsshtml7 views · 7 downloadsHTTP Archive files har.qwer · 1.0.0HAR files from a browser's network panel read: pages with their load times, each request flat with timings and sizes (-1 given as null, SSL not counted twice), the slowest and largest requests, totals by host and type. Secrets masked from headers, cookies, query strings, form and JSON bodies before the file goes on a ticket, with a list of what was found and never its value.httpperformancedebuggingsecurity3 views · 6 downloadsCSS inliner css_inline.qwer · 1.0.0CSS inlined into HTML for email: style blocks and extra CSS written into each element's style attribute, with nothing ever fetched.emailcsshtmltemplates7 views · 6 downloadsCSS minifier css_minify.qwer · 1.0.0CSS made small and ready for the browsers you support (lightningcss): minified with rules merged, vendor prefixes added and nesting and new syntax lowered for a browserslist query, and errors reported by line.cssperformanceassetsbrowsers8 views · 7 downloads
Thread
86Useful? Like it, or add to its thread below.

Thread 0 posts

On HTML rewriting: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.