Three issues surfaced when viewing the ZIM in Kiwix iOS:
1. Blank column at top: hiding img[usemap] left its containing <td> as an
empty box. Now td:has(>img[usemap]) hides the entire nav column.
2. Content clipped on the right: the inner content table had width="435"
as an HTML attribute. Added [width]{width:auto!important} to cancel all
fixed HTML attribute widths on any element (tables, tds, imgs).
3. Spacer column: the 26px <td> holding a 1x1 transparent GIF kept its
allocated space. td:has(>img[src*="trans_1x1"]:only-child) hides it.
Also: overflow-x:hidden on body, box-sizing:border-box globally, and
img{max-width:100%;height:auto} so any inline images stay within column.
Cloning a 1990s/2000s site like paulgraham.com and opening it in Kiwix
on a phone produces microscopic text: the pages use <font size="2">,
table layouts, and no viewport declaration, so the mobile browser shrinks
everything to desktop scale and then the font-size attribute makes it
smaller still.
kage clone --mobile injects two things into every saved page:
- <meta name="viewport" content="width=device-width, initial-scale=1">
so the browser stops shrinking the page
- a <style> block that lifts the base font to 18px, inherits it through
<font> elements (overriding the in-HTML size/face attributes), caps the
content width at 720px, loosens line height to 1.7, and hides
image-map nav elements whose source GIFs 404 offline
The style block goes last in <head> to win specificity ties, and
ensureViewport skips pages that already carry a viewport meta.
Two tests cover the happy path and the no-duplicate case.
A page that declared its charset only in the HTTP Content-Type header,
with no meta charset in the markup, lost that signal once kage saved it
as a file. A reader serving the bytes without a charset then fell back to
its locale encoding and garbled every curly quote, dash, and nbsp.
kage writes UTF-8, so it now inserts a meta charset utf-8 at the top of
head when the page does not already declare one. Verified on the blog
post from the report: the saved HTML now carries the meta and renders the
quotes correctly.
The sanitizer walked only element nodes, so a script hidden in a
downlevel IE conditional comment slipped through. golang.org/x/net/html
parses <!--[if lt IE 9]><script src="..."></script><![endif]--> as a
single comment node whose data holds the raw markup, so the element walk
never sees the <script> and it rendered straight back out, a live-CDN
script reference left sitting in a page kage promises is inert.
Plenty of older docs sites (clojure.org, cordova.apache.org, and the
async library docs among them) still ship html5shiv, respond.js, or
placeholders.js this way.
Drop conditional comments in the walk. The downlevel-hidden form is one
comment and goes whole; the downlevel-revealed form keeps its content,
which lives in sibling nodes, and loses only the two markers.
kage renders every page in headless Chrome, snapshots the final
DOM, strips all JavaScript, and localises CSS, images, and fonts
so a site can be browsed offline as a plain folder of files.
The engine is split into small packages:
urlx deterministic URL to local-path mapping and scope rules
sanitize remove scripts, on* handlers, and javascript: URLs
asset rewrite HTML and CSS references, download assets
browser headless Chrome pool over the DevTools protocol
robots robots.txt matcher
clone the orchestrator: a polite resumable breadth-first crawl
The cli package wires a cobra and fang command surface with two
commands, clone and serve. Every pure package has table tests; the
browser and clone packages add Chrome-driven end-to-end tests that
skip when no browser is present or under -short.
CI runs gofmt, vet, build, race tests, golangci-lint, govulncheck,
and a tidy check on Linux and macOS. A goreleaser config fans one
tag out to archives, deb/rpm/apk, a Chromium-bundled GHCR image,
and the package managers. A tago docs site builds to Pages and
Cloudflare.