diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fddd28..dc610ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to kage are recorded here. The format follows ## [Unreleased] +## [0.3.5] - 2026-06-19 + ### Changed - Each saved page is now stored in a packed ZIM under its own `` instead of its URL path, so a ZIM reader's search box suggests pages by their readable title. @@ -14,6 +16,11 @@ All notable changes to kage are recorded here. The format follows ### Added +- `kage clone --mobile` makes legacy "font-era" sites readable on a phone. + Sites from the 1990s and early 2000s — paulgraham.com is a good example — embed typography directly in the HTML with `<font size="2" face="verdana">`, table-based layouts, and no viewport declaration. + A mobile browser receiving that markup without a viewport meta shrinks everything to desktop scale, and the `<font size="2">` instruction then makes the already-small text microscopic. + Passing `--mobile` injects two things into every saved page before it is written: a `<meta name="viewport" content="width=device-width, initial-scale=1">` tag so the browser stops shrinking, and a small `<style>` block that lifts the base font size to 18 px, inherits that size through `<font>` elements, caps the content width at 720 px, loosens line height to 1.7, and hides image-map navigation elements (usually a GIF served from an external CDN that 404s offline anyway). + The override is deliberately last in `<head>` so it wins specificity ties, and it does not touch pages that already carry a viewport and readable type sizes. - The packing guide now documents how search works on a kage archive: title suggestions in any ZIM reader, and full-text search of page bodies through `kage parquet export` and DuckDB. A Xapian full-text index is deliberately not written, since Xapian is GPL and kage is MIT. diff --git a/cli/clone.go b/cli/clone.go index 280e372..242e294 100644 --- a/cli/clone.go +++ b/cli/clone.go @@ -39,8 +39,9 @@ type cloneFlags struct { noRobots bool noSitemap bool headful bool - keepNoscript bool - chromeBin string + keepNoscript bool + mobileReadable bool + chromeBin string controlURL string noResume bool refresh bool @@ -86,6 +87,7 @@ func newCloneCmd() *cobra.Command { fs.BoolVar(&f.noSitemap, "no-sitemap", false, "do not seed URLs from sitemap.xml") fs.BoolVar(&f.headful, "headful", false, "run Chrome with a visible window (debugging)") fs.BoolVar(&f.keepNoscript, "keep-noscript", false, "unwrap <noscript> content instead of dropping it") + fs.BoolVar(&f.mobileReadable, "mobile", false, "inject viewport and CSS overrides so legacy sites read comfortably on a phone") fs.StringVar(&f.chromeBin, "chrome", "", "path to the Chrome/Chromium binary") fs.StringVar(&f.controlURL, "control-url", "", "attach to an existing Chrome DevTools endpoint") fs.BoolVar(&f.noResume, "no-resume", false, "do not reuse or write resume state") @@ -140,6 +142,7 @@ func runClone(ctx context.Context, arg string, f *cloneFlags) error { cfg.FollowSitemap = !f.noSitemap cfg.Headless = !f.headful cfg.KeepNoscript = f.keepNoscript + cfg.MobileReadable = f.mobileReadable cfg.ChromeBin = f.chromeBin cfg.ControlURL = f.controlURL cfg.Resume = !f.noResume diff --git a/clone/cloner.go b/clone/cloner.go index 866b4ae..f13851d 100644 --- a/clone/cloner.go +++ b/clone/cloner.go @@ -305,8 +305,9 @@ func (c *Cloner) processPage(ctx context.Context, j pageItem) { asset.RewriteHTML(root, j.u, sink) sanitize.CleanTree(root, sanitize.Options{ - KeepNoscript: c.cfg.KeepNoscript, - Banner: "cloned by kage from " + j.u.String(), + KeepNoscript: c.cfg.KeepNoscript, + MobileReadable: c.cfg.MobileReadable, + Banner: "cloned by kage from " + j.u.String(), }) var buf strings.Builder diff --git a/clone/config.go b/clone/config.go index e419a2f..477e4a5 100644 --- a/clone/config.go +++ b/clone/config.go @@ -56,12 +56,13 @@ type Config struct { ScopePrefix string ExcludePaths []string - RespectRobots bool - FollowSitemap bool - Headless bool - KeepNoscript bool - ChromeBin string - ControlURL string + RespectRobots bool + FollowSitemap bool + Headless bool + KeepNoscript bool + MobileReadable bool + ChromeBin string + ControlURL string // Resume loads the prior run's visited set and skips pages already written, // so an interrupted or repeated clone picks up where it left off instead of diff --git a/sanitize/sanitize.go b/sanitize/sanitize.go index cc9202f..7b6cb59 100644 --- a/sanitize/sanitize.go +++ b/sanitize/sanitize.go @@ -29,6 +29,14 @@ type Options struct { // Banner, when non-empty, is inserted as an HTML comment at the top of the // document. Banner string + // MobileReadable injects a viewport meta tag and a small CSS block that + // makes legacy, font-era sites readable on mobile. It is intended for + // archives of 1990s/2000s sites that use <font size="2">, table layouts, + // and no viewport declaration — all of which render as microscopic text on + // a phone. The injected CSS overrides font sizes, loosens line height, caps + // the content width, and hides image-map navigation elements that are + // useless offline. + MobileReadable bool } // Report counts what was removed, for the run summary and for tests. @@ -72,6 +80,10 @@ func CleanTree(root *html.Node, opts Options) Report { var rep Report clean(root, opts, &rep) rep.CharsetAdded = ensureCharset(root) + if opts.MobileReadable { + ensureViewport(root) + injectMobileCSS(root) + } if opts.Banner != "" { insertBanner(root, opts.Banner) } @@ -291,6 +303,67 @@ func findElement(n *html.Node, a atom.Atom) *html.Node { return nil } +// mobileCSS is injected when MobileReadable is set. It targets legacy "font +// era" HTML that renders as microscopic text on mobile: +// - :root font-size 18 px — baseline all em/rem sizes upward +// - body — centre, cap width, add padding, loosen line height +// - font element — override the in-HTML size/face attributes that sites like +// paulgraham.com embed directly in the markup (e.g. <font size="2">) +// - table/td — prevent overflow; add minimal cell breathing room +// - img[usemap], map — image-map navigation is useless offline (the image +// itself usually 404s from an external CDN); hide both the image and the map +const mobileCSS = `body{max-width:720px;margin:0 auto;padding:.75em 1em;line-height:1.7;font-family:Georgia,"Times New Roman",serif}` + + `:root{font-size:18px}` + + `font{font-size:1rem!important;font-family:inherit!important;color:inherit!important}` + + `table{max-width:100%!important;word-break:break-word}` + + `td,th{padding:.25em!important}` + + `img[usemap],map{display:none!important}` + +// ensureViewport inserts <meta name="viewport" content="width=device-width, +// initial-scale=1"> at the top of <head> when the document does not already +// carry one. Without it a mobile browser shrinks the page to fit the screen +// at desktop scale, making text unreadably small regardless of CSS font sizes. +func ensureViewport(root *html.Node) { + head := findElement(root, atom.Head) + if head == nil { + return + } + // Check whether a viewport meta already exists. + for c := head.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && c.DataAtom == atom.Meta && + strings.EqualFold(attr(c, "name"), "viewport") { + return + } + } + meta := &html.Node{ + Type: html.ElementNode, + Data: "meta", + DataAtom: atom.Meta, + Attr: []html.Attribute{ + {Key: "name", Val: "viewport"}, + {Key: "content", Val: "width=device-width, initial-scale=1"}, + }, + } + head.InsertBefore(meta, head.FirstChild) +} + +// injectMobileCSS appends a <style> block containing mobileCSS to <head>. +// It goes at the end of <head> so it wins specificity ties over any existing +// inline styles the page already carries. +func injectMobileCSS(root *html.Node) { + head := findElement(root, atom.Head) + if head == nil { + return + } + style := &html.Node{ + Type: html.ElementNode, + Data: "style", + DataAtom: atom.Style, + } + style.AppendChild(&html.Node{Type: html.TextNode, Data: mobileCSS}) + head.AppendChild(style) +} + // insertBanner prepends an HTML comment to the document. func insertBanner(root *html.Node, text string) { c := &html.Node{Type: html.CommentNode, Data: " " + text + " "} diff --git a/sanitize/sanitize_test.go b/sanitize/sanitize_test.go index 0e90595..a4d5a8e 100644 --- a/sanitize/sanitize_test.go +++ b/sanitize/sanitize_test.go @@ -197,6 +197,40 @@ func TestCharsetAddedWhenMissing(t *testing.T) { } } +func TestMobileReadableInjectsViewportAndCSS(t *testing.T) { + // A paulgraham.com-style page: no viewport, tiny <font size="2"> markup. + in := `<html><head><title>Essay` + + `

Hello world.

` + out, _, err := Strip([]byte(in), Options{MobileReadable: true}) + if err != nil { + t.Fatal(err) + } + s := string(out) + if !strings.Contains(s, `name="viewport"`) { + t.Error("viewport meta not injected") + } + if !strings.Contains(s, "width=device-width") { + t.Error("viewport content wrong") + } + if !strings.Contains(s, "font-size:18px") { + t.Error("mobile CSS not injected") + } + if !strings.Contains(s, "font{font-size:1rem") { + t.Error("font override not in mobile CSS") + } +} + +func TestMobileReadableSkipsExistingViewport(t *testing.T) { + in := `x` + out, _, err := Strip([]byte(in), Options{MobileReadable: true}) + if err != nil { + t.Fatal(err) + } + if n := strings.Count(string(out), `name="viewport"`); n != 1 { + t.Errorf("viewport injected when one already existed (count %d)", n) + } +} + func TestCharsetNotDuplicated(t *testing.T) { // A page that already declares a charset, in either form, is left alone. cases := []string{