Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 105e68f036 | |||
| 792878d6f3 | |||
| 910619bc7a | |||
| e0e4096f93 | |||
| c176719031 | |||
| a1b82ebd1b | |||
| 5ad8fff9df | |||
| 7157144c75 | |||
| de1c0adde3 | |||
| 16bbd1b9d0 | |||
| 3220f6d230 | |||
| 9d452ddf11 | |||
| c8fc6faaa3 | |||
| c5b39d6816 | |||
| 057937938d | |||
| 4e920c57ec | |||
| 2c15f79ff5 | |||
| 060b4b6449 | |||
| 3117c4c55c | |||
| 6655a434b9 | |||
| 8bb1e344d2 | |||
| 122a80210c | |||
| 0a8ab2e238 | |||
| 994e94f3fb | |||
| eb683ddd2c | |||
| c85745b230 | |||
| 602f4dfa40 | |||
| e80d38a8bc | |||
| 8178b767fd | |||
| a68d2b12e0 | |||
| 5cbb7f83ea | |||
| bf850c6b93 | |||
| 83822c1629 | |||
| 3ec5c8fdaf | |||
| 7709bccfcd | |||
| 16c9fcbcfe | |||
| 2d8a64ca6f | |||
| a96233cf73 | |||
| 2064ef918a | |||
| 9eabd93098 |
@@ -26,7 +26,7 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
cache: true
|
||||
- name: install chromium (linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
uses: browser-actions/setup-chrome@v2.1.2
|
||||
id: chrome
|
||||
- name: gofmt
|
||||
run: |
|
||||
@@ -67,13 +67,13 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
cache: true
|
||||
- uses: golangci/golangci-lint-action@v8
|
||||
- uses: golangci/golangci-lint-action@v9.2.1
|
||||
with:
|
||||
version: latest
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
tidy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
webview:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
windows-gui:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -26,14 +26,14 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
# Sitemap lastmod comes from the latest content commit.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout tago
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
repository: tamnd/tago
|
||||
path: .tago-src
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
group: cloudflare-pages-kage
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
sparse-checkout: scripts/
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
if: github.ref_type != 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v6
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
cache: true
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
- uses: goreleaser/goreleaser-action@v7.2.2
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
@@ -49,8 +49,10 @@ jobs:
|
||||
contents: write # create the GitHub release
|
||||
packages: write # push the image to ghcr.io
|
||||
id-token: write # keyless cosign signing
|
||||
env:
|
||||
LINUX_REPO_DISPATCH_TOKEN: ${{ secrets.LINUX_REPO_DISPATCH_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v6
|
||||
@@ -60,19 +62,25 @@ jobs:
|
||||
cache: true
|
||||
|
||||
# Build and ship the linux/arm64 image from the amd64 runner.
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
- uses: docker/setup-qemu-action@v4.1.0
|
||||
- uses: docker/setup-buildx-action@v4.1.0
|
||||
- uses: docker/login-action@v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Tools GoReleaser shells out to for signing and SBOMs.
|
||||
- uses: sigstore/cosign-installer@v3
|
||||
- uses: anchore/sbom-action/download-syft@v0
|
||||
# Pin cosign to the 2.x line. cosign 3.x makes the new bundle format the
|
||||
# default, which ignores the --output-signature/--output-certificate flags
|
||||
# the signs block uses and aborts. Pinning keeps the .sig/.pem outputs and
|
||||
# stops the release tool from floating to a breaking latest.
|
||||
- uses: sigstore/cosign-installer@v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.6.3"
|
||||
- uses: anchore/sbom-action/download-syft@v0.24.0
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
- uses: goreleaser/goreleaser-action@v7.2.2
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
@@ -85,3 +93,20 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }}
|
||||
|
||||
# Rebuild the Linux apt/dnf repository with the packages just released.
|
||||
# Skipped when the dispatch token is unset, so the release never depends
|
||||
# on it being configured.
|
||||
- name: Refresh the Linux package repository
|
||||
if: env.LINUX_REPO_DISPATCH_TOKEN != ''
|
||||
run: |
|
||||
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $LINUX_REPO_DISPATCH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
https://api.github.com/repos/tamnd/linux-repo/dispatches \
|
||||
-d '{"event_type":"package-released"}' || true)
|
||||
if [ "$code" = "204" ]; then
|
||||
echo "Linux repo refresh triggered"
|
||||
else
|
||||
echo "Linux repo refresh not accepted (HTTP $code); skipping"
|
||||
fi
|
||||
|
||||
+5
-4
@@ -151,11 +151,11 @@ homebrew_casks:
|
||||
repository:
|
||||
owner: tamnd
|
||||
name: homebrew-tap
|
||||
token: '{{ envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}'
|
||||
token: '{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}'
|
||||
directory: Casks
|
||||
homepage: https://github.com/tamnd/kage
|
||||
description: Clone any website for offline viewing, with the JavaScript stripped out
|
||||
skip_upload: '{{ if envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}'
|
||||
skip_upload: '{{ if index .Env "HOMEBREW_TAP_GITHUB_TOKEN" }}false{{ else }}true{{ end }}'
|
||||
commit_author:
|
||||
name: Duc-Tam Nguyen
|
||||
email: tamnd87@gmail.com
|
||||
@@ -168,11 +168,12 @@ scoops:
|
||||
repository:
|
||||
owner: tamnd
|
||||
name: scoop-bucket
|
||||
token: '{{ envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}'
|
||||
token: '{{ .Env.SCOOP_BUCKET_GITHUB_TOKEN }}'
|
||||
directory: bucket
|
||||
homepage: https://github.com/tamnd/kage
|
||||
description: Clone any website for offline viewing, with the JavaScript stripped out
|
||||
license: MIT
|
||||
skip_upload: '{{ if envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}'
|
||||
skip_upload: '{{ if index .Env "SCOOP_BUCKET_GITHUB_TOKEN" }}false{{ else }}true{{ end }}'
|
||||
commit_author:
|
||||
name: Duc-Tam Nguyen
|
||||
email: tamnd87@gmail.com
|
||||
|
||||
+80
-1
@@ -6,6 +6,81 @@ All notable changes to kage are recorded here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.6] - 2026-06-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- `kage clone --mobile` now produces a correct layout when the source page uses table-based navigation with image maps, as paulgraham.com does.
|
||||
Three rendering problems appeared in Kiwix iOS after the 0.3.5 release: a tall blank box at the top of every page, content clipped on the right edge, and a narrow spacer column occupying screen space.
|
||||
The blank box came from hiding only the `<img usemap>` element while leaving its `<td>` container in place; the cell collapsed to empty but kept its full height.
|
||||
`td:has(>img[usemap])` now hides the entire nav column rather than just the image inside it.
|
||||
The right-side clip came from `width="435"` set directly as an HTML attribute on the inner content table; the earlier `max-width` CSS rule does not override HTML attributes.
|
||||
A new `[width]{width:auto!important;max-width:100%!important}` rule cancels every fixed HTML width attribute on any element — tables, cells, and images alike — so the content column stretches to fill the phone screen.
|
||||
The spacer column (a 26 px `<td>` holding a 1×1 transparent GIF) kept its allocated width for the same reason; `td:has(>img[src*="trans_1x1"]:only-child)` hides it alongside the nav column.
|
||||
Two additional rules round out the fix: `*{box-sizing:border-box}` prevents padding from pushing content past the viewport edge, and `img{max-width:100%;height:auto}` keeps any inline photos within their column.
|
||||
|
||||
## [0.3.5] - 2026-06-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Each saved page is now stored in a packed ZIM under its own `<title>` instead of its URL path, so a ZIM reader's search box suggests pages by their readable title.
|
||||
Typing into Kiwix's search now offers "Five Founders" or "Female Founders" rather than a filename, because the title pointer list a reader's suggestion search walks carries the real page titles.
|
||||
A page with no `<title>` still falls back to its path, and the per-page title also flows into the `title` column of `kage parquet export`.
|
||||
|
||||
### 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.
|
||||
|
||||
## [0.3.4] - 2026-06-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- `kage serve` now stops on Ctrl-C instead of ignoring it.
|
||||
The preview server was started with a blocking call that never watched for an interrupt, so the only way to stop it was to kill the process.
|
||||
kage now shuts the server down gracefully on an interrupt or a `SIGTERM`, with a short timeout before it forces the listener closed, so a preview exits cleanly. Thanks to Xirui Wang (#35) and Kaidi Zhao (#38).
|
||||
- A page whose JavaScript builds a deeply nested object graph no longer fails to clone.
|
||||
Chrome's DevTools Protocol returns "Object reference chain is too long" while loading such a page, but the HTML has already loaded and the error is only about Chrome's internal object tracking, not the document.
|
||||
kage now recognises that specific error and finishes rendering the page instead of dropping it (reported in #36). Thanks to Gautam Kumar (#39).
|
||||
|
||||
## [0.3.3] - 2026-06-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Chrome no longer downloads a file to your Downloads folder when a crawl follows a link that turns out to be a binary (reported in #32).
|
||||
An extensionless link is queued as a page, so the page worker navigated to it in Chrome, and a link that served a zip or a CSV made Chrome save the file to `~/Downloads`, a surprise side effect of a clone.
|
||||
kage now denies Chrome-initiated downloads browser-wide, since every asset is fetched through kage's own downloader, and detects a navigation whose response is not HTML and reroutes that URL to the asset downloader, where the size and media policy decides whether to localise it or leave it on the live web.
|
||||
|
||||
## [0.3.2] - 2026-06-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Saved pages now declare their character encoding, so text no longer mojibakes in a reader.
|
||||
kage writes every page as UTF-8, but a source that set its charset only in the HTTP `Content-Type` header, with no `<meta charset>` in the markup, lost that signal once the page became a standalone file.
|
||||
A reader serving the bytes without a charset then fell back to its locale encoding and turned every curly quote, dash, and non-breaking space into mojibake (reported in #16 and #29).
|
||||
kage now inserts a `<meta charset="utf-8">` at the top of `<head>` when the page does not already declare one, so the page is self-describing in any reader.
|
||||
|
||||
## [0.3.1] - 2026-06-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- A served mirror whose entry point is a nested page no longer loses its CSS and
|
||||
images when opened at the root. kage saves each page's asset links as
|
||||
mirror-relative paths (`../_kage/...`) computed for that page's own location,
|
||||
but the viewer answered `/` with the main page's bytes in place, so the browser
|
||||
resolved those relative URLs against `/` and missed every one. A
|
||||
`developer.apple.com/documentation` mirror, whose main page is
|
||||
`developer.apple.com/documentation/index.html`, landed at `/` completely
|
||||
unstyled. kage now redirects `/` to the main page's canonical content path, the
|
||||
way the archive's `W/mainPage` redirect already does, so the browser resolves
|
||||
the page's relative assets correctly. Kiwix was unaffected because it follows
|
||||
that redirect itself.
|
||||
|
||||
## [0.3.0] - 2026-06-15
|
||||
|
||||
### Added
|
||||
@@ -180,7 +255,11 @@ can browse offline, with every script stripped out.
|
||||
a multi-arch container image on GHCR (Chromium bundled), checksums, SBOMs, and
|
||||
a cosign signature, all cut from one version tag by GoReleaser.
|
||||
|
||||
[Unreleased]: https://github.com/tamnd/kage/compare/v0.3.0...HEAD
|
||||
[Unreleased]: https://github.com/tamnd/kage/compare/v0.3.4...HEAD
|
||||
[0.3.4]: https://github.com/tamnd/kage/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/tamnd/kage/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/tamnd/kage/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/tamnd/kage/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/tamnd/kage/compare/v0.2.1...v0.3.0
|
||||
[0.2.1]: https://github.com/tamnd/kage/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/tamnd/kage/compare/v0.1.2...v0.2.0
|
||||
|
||||
@@ -24,7 +24,27 @@ Full docs and guides live at **[kage.tamnd.com](https://kage.tamnd.com)**.
|
||||
go install github.com/tamnd/kage/cmd/kage@latest
|
||||
```
|
||||
|
||||
Prefer a prebuilt binary? Grab an archive, a `.deb`/`.rpm`/`.apk`, or a checksum from [releases](https://github.com/tamnd/kage/releases). Or skip installing Chrome yourself and use the container image, which bundles Chromium:
|
||||
Prefer a prebuilt binary? Grab an archive, a `.deb`/`.rpm`/`.apk`, or a checksum from [releases](https://github.com/tamnd/kage/releases). Or let a package manager handle it:
|
||||
|
||||
```bash
|
||||
# Homebrew (macOS)
|
||||
brew install tamnd/tap/kage
|
||||
|
||||
# Scoop (Windows)
|
||||
scoop bucket add tamnd https://github.com/tamnd/scoop-bucket
|
||||
scoop install kage
|
||||
|
||||
# apt (Debian, Ubuntu)
|
||||
curl -fsSL https://tamnd.github.io/linux-repo/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/tamnd.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/tamnd.gpg] https://tamnd.github.io/linux-repo/apt stable main" | sudo tee /etc/apt/sources.list.d/tamnd.list
|
||||
sudo apt update && sudo apt install kage
|
||||
|
||||
# dnf (Fedora, RHEL)
|
||||
sudo dnf config-manager --add-repo https://tamnd.github.io/linux-repo/dnf/tamnd.repo
|
||||
sudo dnf install kage
|
||||
```
|
||||
|
||||
Or skip installing Chrome yourself and use the container image, which bundles Chromium:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD/out:/out" ghcr.io/tamnd/kage clone paulgraham.com
|
||||
|
||||
+132
-3
@@ -67,6 +67,21 @@ type RenderResult struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
// ErrNotHTML reports that a URL kage tried to render as a page is not HTML: the
|
||||
// server returned some other content type (a zip, a CSV, a PDF, a bare image).
|
||||
// Such a URL reaches the page worker when its link carried no file extension to
|
||||
// classify it by. The caller reroutes it to the asset downloader, where the
|
||||
// asset policy decides whether to localise or leave it remote, instead of saving
|
||||
// an empty or broken page or letting Chrome download it (issue #32).
|
||||
type ErrNotHTML struct {
|
||||
URL string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
func (e *ErrNotHTML) Error() string {
|
||||
return fmt.Sprintf("not HTML (%s): %s", e.ContentType, e.URL)
|
||||
}
|
||||
|
||||
// Render navigates to rawURL, lets it settle, and returns the final rendered
|
||||
// HTML. It acquires a page slot from the pool and releases it when done.
|
||||
func (p *Pool) Render(ctx context.Context, rawURL string) (RenderResult, error) {
|
||||
@@ -90,11 +105,33 @@ func (p *Pool) Render(ctx context.Context, rawURL string) (RenderResult, error)
|
||||
|
||||
page = page.Context(ctx).Timeout(p.opts.RenderTimeout)
|
||||
|
||||
if err := page.Navigate(rawURL); err != nil {
|
||||
return RenderResult{}, fmt.Errorf("navigate %s: %w", rawURL, err)
|
||||
// Watch the main document's response so a navigation that turns out to be a
|
||||
// non-HTML resource (a zip, a CSV, a bare image) is caught and handed back for
|
||||
// the asset downloader, rather than rendered as a broken page or, with downloads
|
||||
// denied, left as an aborted navigation (issue #32). The content type arrives in
|
||||
// the response headers whether Chrome renders the body or aborts it as a denied
|
||||
// download, so this catches both.
|
||||
mainContentType := watchMainDocument(page)
|
||||
|
||||
navErr := page.Navigate(rawURL)
|
||||
// A denied download aborts the navigation, so inspect the captured content type
|
||||
// before treating a navigation error as a failure. waitFor gives the response
|
||||
// event a brief moment to be processed; for an HTML page it returns at once.
|
||||
if ct := waitFor(ctx, mainContentType, 2*time.Second); ct != "" && !isHTML(ct) {
|
||||
return RenderResult{}, &ErrNotHTML{URL: rawURL, ContentType: ct}
|
||||
}
|
||||
if navErr != nil {
|
||||
return RenderResult{}, fmt.Errorf("navigate %s: %w", rawURL, navErr)
|
||||
}
|
||||
if err := page.WaitLoad(); err != nil {
|
||||
return RenderResult{}, fmt.Errorf("wait load %s: %w", rawURL, err)
|
||||
// Chrome's DevTools Protocol may return "Object reference chain is too
|
||||
// long" when a page's JavaScript builds deeply nested object graphs.
|
||||
// The page has still loaded its HTML — the error is only about Chrome's
|
||||
// internal object tracking, not about the document. Log the warning and
|
||||
// continue rendering rather than failing the entire page (issue #36).
|
||||
if !isObjRefChainError(err) {
|
||||
return RenderResult{}, fmt.Errorf("wait load %s: %w", rawURL, err)
|
||||
}
|
||||
}
|
||||
settle(page, p.opts.Settle)
|
||||
if p.opts.Scroll {
|
||||
@@ -169,6 +206,21 @@ func (p *Pool) getBrowser() (*rod.Browser, error) {
|
||||
if err := b.Connect(); err != nil {
|
||||
return nil, fmt.Errorf("connect Chrome: %w", err)
|
||||
}
|
||||
|
||||
// kage never wants Chrome to write a file to disk. Every asset is fetched
|
||||
// through kage's own downloader, which applies the size and media policy, so a
|
||||
// Chrome-initiated download is only ever an accident: navigating an <a> link
|
||||
// that turns out to be a binary (a zip, an installer, a CSV) makes Chrome save
|
||||
// it to the user's Downloads folder, a surprise side effect of a crawl
|
||||
// (issue #32). Denying downloads browser-wide stops that. The navigation is
|
||||
// aborted instead, and Render's non-HTML detection reroutes the URL through the
|
||||
// asset downloader, where the asset policy decides its fate. This is
|
||||
// best-effort: if the call is unsupported, the non-HTML detection still keeps
|
||||
// the binary out of the saved mirror.
|
||||
_ = proto.BrowserSetDownloadBehavior{
|
||||
Behavior: proto.BrowserSetDownloadBehaviorBehaviorDeny,
|
||||
}.Call(b)
|
||||
|
||||
p.browser = b
|
||||
return b, nil
|
||||
}
|
||||
@@ -321,6 +373,83 @@ func envBool(name string) (val, ok bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// watchMainDocument subscribes to network responses and returns an accessor for
|
||||
// the main document's content type. The first Document-type response is the main
|
||||
// frame's navigation; later Document responses are sub-frames (iframes), whose
|
||||
// type kage does not police, so only the first is kept. The accessor is safe to
|
||||
// call from another goroutine. Any setup error leaves the accessor returning "",
|
||||
// which the caller reads as "unknown, render normally".
|
||||
func watchMainDocument(page *rod.Page) func() string {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
ct string
|
||||
)
|
||||
if err := (proto.NetworkEnable{}).Call(page); err != nil {
|
||||
return func() string { return "" }
|
||||
}
|
||||
wait := page.EachEvent(func(e *proto.NetworkResponseReceived) {
|
||||
if e.Type != proto.NetworkResourceTypeDocument || e.Response == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if ct == "" {
|
||||
ct = e.Response.MIMEType
|
||||
}
|
||||
mu.Unlock()
|
||||
})
|
||||
// EachEvent's wait blocks until the page context ends, draining events as they
|
||||
// arrive; run it for the page's lifetime. The deferred page.Close in Render
|
||||
// cancels the context and unblocks it.
|
||||
go wait()
|
||||
return func() string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return ct
|
||||
}
|
||||
}
|
||||
|
||||
// waitFor polls get until it returns a non-empty value, the deadline passes, or
|
||||
// the context is cancelled, then returns whatever it last saw. It exists because
|
||||
// the network response is processed on another goroutine, so the value may not be
|
||||
// set the instant Navigate returns; an HTML page sets it within a few
|
||||
// milliseconds, while a never-arriving response simply waits out the deadline.
|
||||
func waitFor(ctx context.Context, get func() string, deadline time.Duration) string {
|
||||
const step = 20 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < deadline; waited += step {
|
||||
if v := get(); v != "" {
|
||||
return v
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return get()
|
||||
case <-time.After(step):
|
||||
}
|
||||
}
|
||||
return get()
|
||||
}
|
||||
|
||||
// isHTML reports whether a document content type is one kage renders and saves as
|
||||
// a page. HTML and XHTML qualify; an empty type is treated as HTML so an unlabelled
|
||||
// response still renders. Anything else (a zip, a CSV, a PDF, a bare image or
|
||||
// JSON) is an asset that reached the page worker because its link carried no file
|
||||
// extension to classify it by.
|
||||
func isHTML(contentType string) bool {
|
||||
mt := strings.ToLower(strings.TrimSpace(contentType))
|
||||
if i := strings.IndexByte(mt, ';'); i >= 0 {
|
||||
mt = strings.TrimSpace(mt[:i])
|
||||
}
|
||||
return mt == "" || mt == "text/html" || mt == "application/xhtml+xml"
|
||||
}
|
||||
|
||||
// isObjRefChainError reports whether err is the Chrome DevTools Protocol error
|
||||
// "Object reference chain is too long" (code -32000). This surfaces when a
|
||||
// page's JavaScript builds deeply nested object graphs. The page has still
|
||||
// loaded — Chrome's internal state tracking hit a limit, not the document
|
||||
// itself (issue #36).
|
||||
func isObjRefChainError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "Object reference chain is too long")
|
||||
}
|
||||
|
||||
// settle waits for the network to go quiet for d, recovering from any rod
|
||||
// panic and capping the wait so a chatty page can never hang the worker.
|
||||
func settle(page *rod.Page, d time.Duration) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -115,3 +116,86 @@ func TestRenderCapturesFinalDOM(t *testing.T) {
|
||||
t.Errorf("render did not capture the JS-built DOM:\n%s", res.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTML(t *testing.T) {
|
||||
cases := []struct {
|
||||
ct string
|
||||
want bool
|
||||
}{
|
||||
{"text/html", true},
|
||||
{"text/html; charset=utf-8", true},
|
||||
{"TEXT/HTML", true},
|
||||
{" text/html ", true},
|
||||
{"application/xhtml+xml", true},
|
||||
{"", true}, // unknown: render rather than misclassify
|
||||
{"application/zip", false},
|
||||
{"text/csv", false},
|
||||
{"application/pdf", false},
|
||||
{"image/png", false},
|
||||
{"application/json", false},
|
||||
{"application/octet-stream", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isHTML(c.ct); got != c.want {
|
||||
t.Errorf("isHTML(%q) = %v, want %v", c.ct, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRoutesNonHTML(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("render test drives Chrome; skipped under -short")
|
||||
}
|
||||
if _, ok := LookChrome(); !ok {
|
||||
t.Skip("no Chrome/Chromium found; skipping render test")
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/page":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<!doctype html><html><body><p>a real page</p></body></html>`))
|
||||
case "/file.zip", "/download":
|
||||
// A binary served with no useful extension on the path, the shape that
|
||||
// makes Chrome download to ~/Downloads when navigated to (issue #32).
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write([]byte("PK\x03\x04 not really a zip"))
|
||||
case "/data":
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
_, _ = w.Write([]byte("a,b\n1,2\n"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := New(Options{Headless: true, Workers: 1, Settle: 300 * time.Millisecond, RenderTimeout: 20 * time.Second})
|
||||
defer func() { _ = p.Close() }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// A real HTML page renders as before.
|
||||
if res, err := p.Render(ctx, srv.URL+"/page"); err != nil {
|
||||
t.Errorf("render HTML page: %v", err)
|
||||
} else if !strings.Contains(res.HTML, "a real page") {
|
||||
t.Errorf("HTML page did not render:\n%s", res.HTML)
|
||||
}
|
||||
|
||||
// Non-HTML navigation targets come back as *ErrNotHTML so the caller can route
|
||||
// them to the asset downloader instead of saving a broken page or downloading.
|
||||
for _, tc := range []struct{ path, wantCT string }{
|
||||
{"/download", "application/zip"},
|
||||
{"/data", "text/csv"},
|
||||
} {
|
||||
_, err := p.Render(ctx, srv.URL+tc.path)
|
||||
var notHTML *ErrNotHTML
|
||||
if !errors.As(err, ¬HTML) {
|
||||
t.Errorf("Render(%s) error = %v, want *ErrNotHTML", tc.path, err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(notHTML.ContentType, tc.wantCT) {
|
||||
t.Errorf("Render(%s) content type = %q, want %q", tc.path, notHTML.ContentType, tc.wantCT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-30
@@ -16,36 +16,37 @@ import (
|
||||
|
||||
// cloneFlags holds the parsed flag values for one invocation.
|
||||
type cloneFlags struct {
|
||||
out string
|
||||
reserved string
|
||||
workers int
|
||||
assetWorkers int
|
||||
browserPages int
|
||||
maxPages int
|
||||
maxDepth int
|
||||
traversal string
|
||||
maxAssetMB int64
|
||||
keepMedia bool
|
||||
skipExt []string
|
||||
allAssetHosts bool
|
||||
timeout time.Duration
|
||||
settle time.Duration
|
||||
renderTO time.Duration
|
||||
scroll bool
|
||||
userAgent string
|
||||
subdomains bool
|
||||
scopePrefix string
|
||||
exclude []string
|
||||
noRobots bool
|
||||
noSitemap bool
|
||||
headful bool
|
||||
keepNoscript bool
|
||||
chromeBin string
|
||||
controlURL string
|
||||
noResume bool
|
||||
refresh bool
|
||||
force bool
|
||||
quiet bool
|
||||
out string
|
||||
reserved string
|
||||
workers int
|
||||
assetWorkers int
|
||||
browserPages int
|
||||
maxPages int
|
||||
maxDepth int
|
||||
traversal string
|
||||
maxAssetMB int64
|
||||
keepMedia bool
|
||||
skipExt []string
|
||||
allAssetHosts bool
|
||||
timeout time.Duration
|
||||
settle time.Duration
|
||||
renderTO time.Duration
|
||||
scroll bool
|
||||
userAgent string
|
||||
subdomains bool
|
||||
scopePrefix string
|
||||
exclude []string
|
||||
noRobots bool
|
||||
noSitemap bool
|
||||
headful bool
|
||||
keepNoscript bool
|
||||
mobileReadable bool
|
||||
chromeBin string
|
||||
controlURL string
|
||||
noResume bool
|
||||
refresh bool
|
||||
force bool
|
||||
quiet bool
|
||||
}
|
||||
|
||||
func newCloneCmd() *cobra.Command {
|
||||
@@ -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
|
||||
|
||||
+24
-3
@@ -1,11 +1,13 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -24,14 +26,14 @@ func newServeCmd() *cobra.Command {
|
||||
if len(args) == 1 {
|
||||
dir = args[0]
|
||||
}
|
||||
return runServe(dir, addr)
|
||||
return runServe(cmd.Context(), dir, addr)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&addr, "addr", "a", "127.0.0.1:8800", "address to listen on")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runServe(dir, addr string) error {
|
||||
func runServe(ctx context.Context, dir, addr string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot serve %q: %w", dir, err)
|
||||
@@ -50,5 +52,24 @@ func runServe(dir, addr string) error {
|
||||
fmt.Fprintln(os.Stderr, styleTitle.Render("kage serve")+" "+styleDim.Render(abs))
|
||||
fmt.Fprintln(os.Stderr, " open "+styleAccent.Render("http://"+ln.Addr().String()))
|
||||
fmt.Fprintln(os.Stderr, styleDim.Render(" press Ctrl-C to stop"))
|
||||
return srv.Serve(ln)
|
||||
|
||||
srvErr := make(chan error, 1)
|
||||
go func() { srvErr <- srv.Serve(ln) }()
|
||||
|
||||
select {
|
||||
case err := <-srvErr:
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
_ = srv.Close()
|
||||
}
|
||||
if err := <-srvErr; err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+19
-2
@@ -255,6 +255,22 @@ func (c *Cloner) processPage(ctx context.Context, j pageItem) {
|
||||
|
||||
res, err := c.pool.Render(ctx, j.u.String())
|
||||
if err != nil {
|
||||
var notHTML *browser.ErrNotHTML
|
||||
if errors.As(err, ¬HTML) {
|
||||
// The URL is not a page but a file (a zip, a CSV, a bare image) that
|
||||
// reached the page worker through an extensionless link. Hand it to the
|
||||
// asset downloader, where the size and media policy decides whether to
|
||||
// localise it or leave it remote, rather than saving a broken page or
|
||||
// letting Chrome download it to the user's Downloads folder (issue #32).
|
||||
c.front.markVisited(key)
|
||||
if c.wantAsset(j.u) {
|
||||
c.enqueueAsset(ctx, j.u, "")
|
||||
c.logf("not a page, fetching as asset (%s): %s", notHTML.ContentType, j.u.String())
|
||||
} else {
|
||||
c.logf("not a page, left on the live web (%s): %s", notHTML.ContentType, j.u.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
c.failPage(j.u.String(), fmt.Errorf("render: %w", err))
|
||||
return
|
||||
}
|
||||
@@ -289,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
|
||||
|
||||
@@ -261,6 +261,79 @@ func TestCloneRefreshReRenders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloneRoutesNonHTMLToAsset guards issue #32: an extensionless link that
|
||||
// turns out to be a file (a zip) is classified as a page up front, but once the
|
||||
// page worker sees it is not HTML it must be handed to the asset downloader, not
|
||||
// saved as a broken page nor downloaded by Chrome to ~/Downloads.
|
||||
func TestCloneRoutesNonHTMLToAsset(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("clone test drives Chrome; skipped under -short")
|
||||
}
|
||||
if _, ok := browser.LookChrome(); !ok {
|
||||
t.Skip("no Chrome/Chromium found; skipping clone test")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// The link has no extension, so it is queued as a page; the server then
|
||||
// answers it with a zip.
|
||||
_, _ = w.Write([]byte(`<!doctype html><html><body>
|
||||
<h1>Home</h1><a href="/download">grab the bundle</a></body></html>`))
|
||||
})
|
||||
mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write([]byte("PK\x03\x04 pretend bundle"))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
seed, err := urlx.ParseSeed(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse seed: %v", err)
|
||||
}
|
||||
|
||||
out := t.TempDir()
|
||||
cfg := DefaultConfig()
|
||||
cfg.OutDir = out
|
||||
cfg.Settle = 300 * time.Millisecond
|
||||
cfg.RenderTimeout = 20 * time.Second
|
||||
cfg.Timeout = 10 * time.Second
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := New(seed, cfg, t.Logf).Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
root := res.OutDir
|
||||
// The home page is a real page and is written.
|
||||
if !fileExists(filepath.Join(root, "index.html")) {
|
||||
t.Error("home page was not written")
|
||||
}
|
||||
// The zip is NOT saved as a page: no download/index.html exists.
|
||||
if fileExists(filepath.Join(root, "download", "index.html")) {
|
||||
t.Error("non-HTML target was saved as a page")
|
||||
}
|
||||
// The zip is fetched as an asset under the reserved tree instead.
|
||||
if res.Assets < 1 {
|
||||
t.Errorf("expected the zip to be fetched as an asset, assets=%d", res.Assets)
|
||||
}
|
||||
assetDir := filepath.Join(root, cfg.Reserved)
|
||||
if !anyFileUnder(t, assetDir, "download") {
|
||||
t.Error("the zip was not downloaded into the reserved asset tree")
|
||||
}
|
||||
if res.PageErrors != 0 {
|
||||
t.Errorf("a rerouted non-HTML target must not count as a page error, got %d", res.PageErrors)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
|
||||
+7
-6
@@ -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
|
||||
|
||||
+8
-1
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/tamnd/kage/cli"
|
||||
"github.com/tamnd/kage/viewer"
|
||||
@@ -18,7 +19,13 @@ func main() {
|
||||
// thread; in the default build this is a harmless no-op.
|
||||
viewer.LockMainThread()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
stop()
|
||||
}()
|
||||
|
||||
os.Exit(cli.Execute(ctx))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Installation"
|
||||
description: "Install kage from Go, Homebrew, a release archive, a Linux package, or the container image, and point it at a browser."
|
||||
description: "Install kage from Go, Homebrew, Scoop, a release archive, a Linux package, or the container image, and point it at a browser."
|
||||
weight: 20
|
||||
---
|
||||
|
||||
@@ -12,26 +12,54 @@ kage is a single binary. Pick whichever channel suits you.
|
||||
go install github.com/tamnd/kage/cmd/kage@latest
|
||||
```
|
||||
|
||||
## Homebrew
|
||||
## Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew install tamnd/tap/kage
|
||||
```
|
||||
|
||||
The cask installs the prebuilt macOS binary. On Linux, use the packages below or
|
||||
`go install`.
|
||||
|
||||
## Scoop (Windows)
|
||||
|
||||
```bash
|
||||
scoop bucket add tamnd https://github.com/tamnd/scoop-bucket
|
||||
scoop install kage
|
||||
```
|
||||
|
||||
## Linux (apt and dnf)
|
||||
|
||||
A signed apt and dnf repository tracks every release, so `apt upgrade` and
|
||||
`dnf upgrade` keep kage current.
|
||||
|
||||
```bash
|
||||
# Debian, Ubuntu
|
||||
curl -fsSL https://tamnd.github.io/linux-repo/gpg.key \
|
||||
| sudo gpg --dearmor -o /usr/share/keyrings/tamnd.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/tamnd.gpg] https://tamnd.github.io/linux-repo/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/tamnd.list
|
||||
sudo apt update && sudo apt install kage
|
||||
|
||||
# Fedora, RHEL
|
||||
sudo dnf config-manager --add-repo https://tamnd.github.io/linux-repo/dnf/tamnd.repo
|
||||
sudo dnf install kage
|
||||
```
|
||||
|
||||
## Release archives and Linux packages
|
||||
|
||||
Every [release](https://github.com/tamnd/kage/releases) attaches `tar.gz`
|
||||
archives (and a `.zip` for Windows) for Linux, macOS, Windows, and FreeBSD, plus
|
||||
`.deb`, `.rpm`, and `.apk` packages and a `checksums.txt` with a cosign
|
||||
signature. Download the one for your platform, extract `kage`, and put it on your
|
||||
`PATH`.
|
||||
`PATH`. To install a package directly without the repo above:
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo dpkg -i kage_*_linux_amd64.deb
|
||||
sudo dpkg -i kage_*_amd64.deb
|
||||
|
||||
# Fedora/RHEL
|
||||
sudo rpm -i kage_*_linux_amd64.rpm
|
||||
sudo rpm -i kage-*.x86_64.rpm
|
||||
```
|
||||
|
||||
## Container
|
||||
|
||||
@@ -41,7 +41,18 @@ kage open paulgraham.com.zim # read it back with kage
|
||||
kiwix-serve paulgraham.com.zim # or serve it with Kiwix at http://localhost
|
||||
```
|
||||
|
||||
You can also double-click the file in the [Kiwix desktop app](https://kiwix.org/en/applications/), or load it on Kiwix for Android or iOS to read your mirror on your phone. kage writes the metadata the format and `zimcheck` treat as mandatory, including the title, description, and the favicon Kiwix shows as the book icon in its library, so the archive shows up properly rather than as an untitled, iconless entry. One caveat: kage does not write the full-text search index that Kiwix's own packs ship with, so browsing works everywhere while in-reader search is limited.
|
||||
You can also double-click the file in the [Kiwix desktop app](https://kiwix.org/en/applications/), or load it on Kiwix for Android or iOS to read your mirror on your phone. kage writes the metadata the format and `zimcheck` treat as mandatory, including the title, description, and the favicon Kiwix shows as the book icon in its library, so the archive shows up properly rather than as an untitled, iconless entry.
|
||||
|
||||
### Searching a packed mirror
|
||||
|
||||
Each page is stored under its own real `<title>`, so the search box in Kiwix (and any other ZIM reader) suggests pages by their readable title: type `five` into a paulgraham.com archive and it offers "Five Founders" and "Female Founders", not a filename. This title search works in every reader with no extra index.
|
||||
|
||||
What kage does not write is a Xapian full-text index, the separate search database that lets Kiwix match words inside a page's body. Xapian is GPL and kage is MIT, so linking it in would change kage's license; rather than do that, kage leaves full-text search to its own columnar export. `kage parquet export mirror.zim` writes one row per page with a `text` column, which [DuckDB](https://duckdb.org) searches with its `fts` extension or a plain `ILIKE`, fully offline and ranked:
|
||||
|
||||
```bash
|
||||
kage parquet export paulgraham.com.zim -o paulgraham.parquet
|
||||
duckdb -c "SELECT url FROM 'paulgraham.parquet' WHERE text ILIKE '%schlep blindness%'"
|
||||
```
|
||||
|
||||
## A self-contained binary
|
||||
|
||||
|
||||
@@ -6,6 +6,31 @@ weight: 40
|
||||
|
||||
The authoritative, commit-level history lives in [`CHANGELOG.md`](https://github.com/tamnd/kage/blob/main/CHANGELOG.md) and on the [releases page](https://github.com/tamnd/kage/releases). This page summarises each version.
|
||||
|
||||
## v0.3.4
|
||||
|
||||
Two community fixes: a clean stop for `kage serve`, and pages with heavy JavaScript that used to be dropped.
|
||||
|
||||
- **`kage serve` stops on Ctrl-C.** The preview server was started with a blocking call that never watched for an interrupt, so stopping it meant killing the process. kage now shuts the server down gracefully on an interrupt or a `SIGTERM`, with a short timeout before forcing the listener closed. Thanks to Xirui Wang ([#35](https://github.com/tamnd/kage/pull/35)) and Kaidi Zhao ([#38](https://github.com/tamnd/kage/pull/38)).
|
||||
- **Pages with deeply nested JavaScript still clone.** Chrome's DevTools Protocol returns "Object reference chain is too long" while loading a page whose script builds a deeply nested object graph, but the page's HTML has already loaded and the error is only about Chrome's internal object tracking. kage now recognises that error and finishes rendering instead of dropping the page ([#36](https://github.com/tamnd/kage/issues/36)). Thanks to Gautam Kumar ([#39](https://github.com/tamnd/kage/pull/39)).
|
||||
|
||||
## v0.3.3
|
||||
|
||||
A fix for Chrome saving a file to your Downloads folder mid-crawl.
|
||||
|
||||
- **A crawl never writes to your Downloads folder.** A link with no file extension is queued as a page, so the page worker opened it in Chrome. When such a link served a binary, a zip or a CSV, Chrome saved the file to `~/Downloads`, a surprise side effect of running a clone ([#32](https://github.com/tamnd/kage/issues/32)). kage now denies Chrome-initiated downloads outright, since every asset is fetched through kage's own downloader and the browser never needs to write a file. As a second layer, kage detects a navigation whose response is not HTML and reroutes that URL to the asset downloader, where the size and media policy decides whether to localise it or leave it on the live web, instead of saving a broken page.
|
||||
|
||||
## v0.3.2
|
||||
|
||||
A fix for garbled text on pages that did not carry a charset of their own.
|
||||
|
||||
- **Saved pages declare UTF-8.** kage writes every page as UTF-8, but a site that set its charset only in the HTTP `Content-Type` header, with no `<meta charset>` in the markup, lost that signal once the page became a standalone file. A reader serving the bytes without a charset fell back to its locale encoding and turned every curly quote, dash, and non-breaking space into mojibake. kage now inserts a `<meta charset="utf-8">` at the top of `<head>` when the page does not already declare one, so the page renders correctly in any reader.
|
||||
|
||||
## v0.3.1
|
||||
|
||||
A fix for broken styling when a packed mirror's home page is a nested page.
|
||||
|
||||
- **`/` redirects to the main page instead of serving it in place.** A page's saved asset links are mirror-relative (`../_kage/...`), computed for that page's own location. The viewer was answering `/` with the main page's bytes directly, so the browser resolved those links against `/` and 404ed the page's CSS and images. A `developer.apple.com/documentation` mirror opened at `/` came up completely unstyled. kage now redirects `/` to the main page's canonical path, the way the archive's `W/mainPage` redirect does, so relative assets resolve correctly. Kiwix already followed that redirect, so it was never affected.
|
||||
|
||||
## v0.3.0
|
||||
|
||||
Leaner mirrors, and a way to publish one as a dataset. A clone now keeps the assets that make a site readable offline and leaves the bulk downloads on the live web, and a packed archive converts to a columnar table that drops straight into dataset tooling.
|
||||
|
||||
@@ -326,6 +326,24 @@ func TestHandler(t *testing.T) {
|
||||
if code, body := get("/"); code != 200 || !bytes.Contains([]byte(body), []byte("Example Home")) {
|
||||
t.Errorf("GET / = %d %.30q", code, body)
|
||||
}
|
||||
|
||||
// "/" must redirect to the main page's canonical content path, not serve its
|
||||
// bytes in place, so the page's mirror-relative asset URLs resolve against the
|
||||
// right base instead of 404ing.
|
||||
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := noFollow.Get(srv.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Errorf("GET / status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "/index.html" {
|
||||
t.Errorf("GET / Location = %q, want %q", loc, "/index.html")
|
||||
}
|
||||
if code, _ := get("/about/index.html"); code != 200 {
|
||||
t.Errorf("GET /about/index.html = %d", code)
|
||||
}
|
||||
@@ -336,3 +354,80 @@ func TestHandler(t *testing.T) {
|
||||
t.Errorf("GET missing = %d, want 404", code)
|
||||
}
|
||||
}
|
||||
|
||||
// titleOf returns the stored entry title for a content url, scanning entries in
|
||||
// url order since the reader exposes titles only through EntryAt.
|
||||
func titleOf(t *testing.T, r *zim.Reader, url string) string {
|
||||
t.Helper()
|
||||
for i := uint32(0); i < r.Count(); i++ {
|
||||
e, err := r.EntryAt(i)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if e.Namespace == zim.NamespaceContent && e.URL == url {
|
||||
return e.Title
|
||||
}
|
||||
}
|
||||
t.Fatalf("no content entry for %q", url)
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestBuildZIMPageTitles checks that each HTML page entry carries its own
|
||||
// <title> (collapsed to one line), that a page with no title falls back to its
|
||||
// url, and that a non-HTML asset keeps the url as its title.
|
||||
func TestBuildZIMPageTitles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
host := filepath.Join(root, "example.com")
|
||||
files := map[string]string{
|
||||
"index.html": "<!doctype html><title>Home</title><h1>Hi</h1>",
|
||||
"essay/index.html": "<!doctype html><title>\n A Long\n Title </title><p>x</p>",
|
||||
"bare/index.html": "<!doctype html><h1>No title here</h1>",
|
||||
"logo.png": "\x89PNGfake",
|
||||
}
|
||||
for rel, body := range files {
|
||||
p := filepath.Join(host, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
out := filepath.Join(t.TempDir(), "titles.zim")
|
||||
if _, _, err := BuildZIM(host, ZIMOptions{Out: out, Date: "2026-06-14"}); err != nil {
|
||||
t.Fatalf("BuildZIM: %v", err)
|
||||
}
|
||||
r, err := zim.Open(out)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
cases := map[string]string{
|
||||
"index.html": "Home",
|
||||
"essay/index.html": "A Long Title", // newlines and runs collapsed to single spaces
|
||||
"bare/index.html": "bare/index.html",
|
||||
"logo.png": "logo.png",
|
||||
}
|
||||
for url, want := range cases {
|
||||
if got := titleOf(t, r, url); got != want {
|
||||
t.Errorf("title of %q = %q, want %q", url, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseSpaces(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" hello world ": "hello world",
|
||||
"line\n\tone\r\n two": "line one two",
|
||||
"": "",
|
||||
" ": "",
|
||||
"single": "single",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := collapseSpaces(in); got != want {
|
||||
t.Errorf("collapseSpaces(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -8,14 +8,24 @@ import (
|
||||
"github.com/tamnd/kage/zim"
|
||||
)
|
||||
|
||||
// Handler serves a ZIM archive over HTTP. "/" maps to the archive's main page;
|
||||
// "/a/b.png" maps to the C/a/b.png content entry. Because the saved HTML's links
|
||||
// are mirror-relative paths, and those are exactly the C urls, a click in a
|
||||
// Handler serves a ZIM archive over HTTP. "/" redirects to the archive's main
|
||||
// page; "/a/b.png" maps to the C/a/b.png content entry. Because the saved HTML's
|
||||
// links are mirror-relative paths, and those are exactly the C urls, a click in a
|
||||
// served page hits the right entry with no rewriting. A miss is a plain 404.
|
||||
func Handler(r *zim.Reader) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
p := strings.TrimPrefix(req.URL.Path, "/")
|
||||
if p == "" {
|
||||
// The main page's saved HTML carries mirror-relative asset URLs
|
||||
// (../_kage/...) computed for its own nested location, so serving its
|
||||
// bytes at "/" would resolve them against the wrong base and 404 the
|
||||
// page's CSS and images. Redirect to the page's canonical content path
|
||||
// instead, the way the archive's W/mainPage redirect does, so the
|
||||
// browser resolves those relative URLs correctly.
|
||||
if ns, url, ok := r.MainPageRef(); ok && ns == zim.NamespaceContent {
|
||||
http.Redirect(w, req, "/"+url, http.StatusFound)
|
||||
return
|
||||
}
|
||||
blob, err := r.MainPage()
|
||||
if err != nil {
|
||||
http.NotFound(w, req)
|
||||
|
||||
+24
-5
@@ -170,11 +170,16 @@ func buildWriter(mirrorDir string, opts ZIMOptions) (*zim.Writer, *clusterCache,
|
||||
return err
|
||||
}
|
||||
mime := MimeForExt(rel)
|
||||
title := ""
|
||||
if mime == "text/html" {
|
||||
htmlPages = append(htmlPages, rel)
|
||||
// Store the page's real <title> on its entry, not the URL path, so a
|
||||
// ZIM reader's search box and suggestions match the readable title.
|
||||
// An empty result leaves the writer to fall back to the url.
|
||||
title = htmlTitleOfBytes(data)
|
||||
}
|
||||
counts[mime]++
|
||||
w.AddContent(zim.NamespaceContent, rel, "", mime, data)
|
||||
w.AddContent(zim.NamespaceContent, rel, title, mime, data)
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
@@ -240,16 +245,30 @@ func htmlTitleOf(mirrorDir, mainURL string) string {
|
||||
if mainURL == "" {
|
||||
return ""
|
||||
}
|
||||
f, err := os.Open(filepath.Join(mirrorDir, filepath.FromSlash(mainURL)))
|
||||
data, err := os.ReadFile(filepath.Join(mirrorDir, filepath.FromSlash(mainURL)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
doc, err := html.Parse(f)
|
||||
return htmlTitleOfBytes(data)
|
||||
}
|
||||
|
||||
// htmlTitleOfBytes returns the first <title> in the given HTML with surrounding
|
||||
// and internal whitespace collapsed, or "" if the bytes do not parse or carry
|
||||
// no title. Page entries use it so a ZIM reader shows the page's real title
|
||||
// instead of its URL path.
|
||||
func htmlTitleOfBytes(data []byte) string {
|
||||
doc, err := html.Parse(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(findTitle(doc))
|
||||
return collapseSpaces(findTitle(doc))
|
||||
}
|
||||
|
||||
// collapseSpaces trims s and replaces every run of whitespace (including the
|
||||
// newlines a wrapped <title> can carry) with a single space, so a title reads
|
||||
// as one clean line in a reader's library and search results.
|
||||
func collapseSpaces(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// findTitle returns the text of the first <title> element in depth-first order.
|
||||
|
||||
@@ -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.
|
||||
@@ -41,6 +49,7 @@ type Report struct {
|
||||
MetaRefreshRemoved int
|
||||
DeadLinksRemoved int
|
||||
CondCommentsRemoved int
|
||||
CharsetAdded bool
|
||||
}
|
||||
|
||||
// jsURLAttrs are attributes whose value may be a javascript: URL.
|
||||
@@ -70,6 +79,11 @@ func Strip(doc []byte, opts Options) ([]byte, Report, error) {
|
||||
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)
|
||||
}
|
||||
@@ -227,6 +241,145 @@ func unwrapNoscript(parent, ns *html.Node) {
|
||||
parent.RemoveChild(ns)
|
||||
}
|
||||
|
||||
// ensureCharset guarantees the document declares UTF-8, inserting a
|
||||
// <meta charset="utf-8"> at the top of <head> when none is present, and reports
|
||||
// whether it added one. kage renders every saved page as UTF-8, but a source
|
||||
// that set its charset only in the HTTP Content-Type header, with no <meta>
|
||||
// charset in the markup, loses that signal once the page is a standalone file.
|
||||
// A reader then serving the bytes without a charset falls back to its locale
|
||||
// encoding and mojibakes every multibyte character (curly quotes, dashes, a
|
||||
// non-breaking space). Declaring the charset in the markup makes the page
|
||||
// self-describing in any reader, kage's own viewer and Kiwix alike.
|
||||
func ensureCharset(root *html.Node) bool {
|
||||
head := findElement(root, atom.Head)
|
||||
if head == nil {
|
||||
return false
|
||||
}
|
||||
if hasCharsetMeta(head) {
|
||||
return false
|
||||
}
|
||||
meta := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "meta",
|
||||
DataAtom: atom.Meta,
|
||||
Attr: []html.Attribute{{Key: "charset", Val: "utf-8"}},
|
||||
}
|
||||
// The declaration must precede any content for a reader to honour it, so it
|
||||
// goes first in <head>.
|
||||
head.InsertBefore(meta, head.FirstChild)
|
||||
return true
|
||||
}
|
||||
|
||||
// hasCharsetMeta reports whether head already declares a character encoding,
|
||||
// either as <meta charset="..."> or the older <meta http-equiv="Content-Type"
|
||||
// content="...; charset=...">.
|
||||
func hasCharsetMeta(head *html.Node) bool {
|
||||
for c := head.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type != html.ElementNode || c.DataAtom != atom.Meta {
|
||||
continue
|
||||
}
|
||||
if attr(c, "charset") != "" {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(attr(c, "http-equiv"), "content-type") &&
|
||||
strings.Contains(strings.ToLower(attr(c, "content")), "charset=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findElement returns the first element node of the given atom in document
|
||||
// order, or nil if none exists.
|
||||
func findElement(n *html.Node, a atom.Atom) *html.Node {
|
||||
if n.Type == html.ElementNode && n.DataAtom == a {
|
||||
return n
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if found := findElement(c, a); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mobileCSS is injected when MobileReadable is set. It rewrites font-era
|
||||
// HTML for comfortable reading on a phone. Key rules:
|
||||
//
|
||||
// - box-sizing:border-box — makes padding predictable in a layout built with
|
||||
// HTML width attributes, so our padding doesn't cause overflow.
|
||||
// - body — no fixed max-width here; let the table rules handle width instead.
|
||||
// overflow-x:hidden catches any stray overflow without a scrollbar.
|
||||
// - font element — overrides in-HTML size/face attributes (e.g. <font size="2">).
|
||||
// - [width],[height] — cancels all HTML attribute widths/heights on every
|
||||
// element (tables, tds, imgs, etc.) so fixed-pixel columns become fluid.
|
||||
// - table — fluid, auto layout, no horizontal scroll.
|
||||
// - td — auto width so a three-column table (nav | spacer | content) collapses
|
||||
// to one usable column once the nav td is hidden.
|
||||
// - img — responsive: never wider than its container.
|
||||
// - td:has(>img[usemap]) — hides the entire nav column td, not just the image
|
||||
// inside it; hiding only the img left a tall empty white box.
|
||||
// - td:has(>img[src*="trans_1x1"]) — hides the 26 px spacer column td (a 1×1
|
||||
// transparent GIF whose only job was spacing in the original table layout).
|
||||
const mobileCSS = `*{box-sizing:border-box}` +
|
||||
`:root{font-size:18px}` +
|
||||
`body{margin:0;padding:.75em 1em;line-height:1.7;font-family:Georgia,"Times New Roman",serif;overflow-x:hidden}` +
|
||||
`font{font-size:1rem!important;font-family:inherit!important;color:inherit!important}` +
|
||||
`[width]{width:auto!important;max-width:100%!important}` +
|
||||
`[height]{height:auto!important}` +
|
||||
`table{width:100%!important;max-width:100%!important;table-layout:auto!important;border-collapse:collapse!important;word-break:break-word}` +
|
||||
`td,th{width:auto!important;max-width:100%!important;padding:.35em .5em!important;vertical-align:top!important;overflow-wrap:break-word}` +
|
||||
`img{max-width:100%!important;height:auto!important}` +
|
||||
`img[usemap],map{display:none!important}` +
|
||||
`td:has(>img[usemap]),td:has(>map){display:none!important}` +
|
||||
`img[src*="trans_1x1"],img[src*="spacer"],img[height="1"],img[width="1"]{display:none!important}` +
|
||||
`td:has(>img[src*="trans_1x1"]:only-child),td:has(>img[height="1"]:only-child){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 + " "}
|
||||
|
||||
@@ -166,3 +166,121 @@ func TestKeepMetaRefreshPlain(t *testing.T) {
|
||||
t.Error("JS-target meta refresh must be removed regardless")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetAddedWhenMissing(t *testing.T) {
|
||||
// A page whose source declared its charset only in the HTTP header has no
|
||||
// <meta charset>. The saved file must gain one so a reader does not fall back
|
||||
// to its locale encoding and mojibake the UTF-8 text.
|
||||
in := `<html><head><title>Quotes</title></head><body><p>` +
|
||||
"“curly” — café</p></body></html>"
|
||||
out, rep, err := Strip([]byte(in), Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rep.CharsetAdded {
|
||||
t.Error("CharsetAdded = false, want true")
|
||||
}
|
||||
s := string(out)
|
||||
if !strings.Contains(strings.ToLower(s), `<meta charset="utf-8"/>`) {
|
||||
t.Errorf("expected an injected meta charset:\n%s", s)
|
||||
}
|
||||
// It must sit at the very start of <head>, before any content.
|
||||
headIdx := strings.Index(s, "<head>")
|
||||
metaIdx := strings.Index(strings.ToLower(s), "<meta charset")
|
||||
titleIdx := strings.Index(s, "<title>")
|
||||
if headIdx >= metaIdx || metaIdx >= titleIdx {
|
||||
t.Errorf("meta charset must come first in head (head=%d meta=%d title=%d)", headIdx, metaIdx, titleIdx)
|
||||
}
|
||||
// The original bytes are preserved as UTF-8.
|
||||
if !strings.Contains(s, "café") {
|
||||
t.Error("UTF-8 content should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileReadableInjectsViewportAndCSS(t *testing.T) {
|
||||
// A paulgraham.com-style page: no viewport, tiny <font size="2"> markup.
|
||||
in := `<html><head><title>Essay</title></head>` +
|
||||
`<body><font size="2" face="verdana"><p>Hello world.</p></font></body></html>`
|
||||
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")
|
||||
}
|
||||
// Fluid table rules must be present.
|
||||
if !strings.Contains(s, "table{width:100%") {
|
||||
t.Error("fluid table rule missing")
|
||||
}
|
||||
// [width] override must be present so fixed HTML width attributes are cancelled.
|
||||
if !strings.Contains(s, "[width]{width:auto") {
|
||||
t.Error("[width] override missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileReadableHidesNavColumnTd(t *testing.T) {
|
||||
// paulgraham.com wraps its image-map nav in a <td>. Hiding only the img
|
||||
// leaves a tall empty box; the CSS must also target the containing td.
|
||||
in := `<html><head><title>T</title></head><body>` +
|
||||
`<table><tr>` +
|
||||
`<td><map name="nav"><area shape="rect" coords="0,0,67,21" href="index.html"></map>` +
|
||||
`<img src="nav.gif" width="69" height="357" usemap="#nav"/></td>` +
|
||||
`<td><img src="https://cdn.example.com/trans_1x1.gif" height="1" width="26"/></td>` +
|
||||
`<td><p>Essay text.</p></td>` +
|
||||
`</tr></table>` +
|
||||
`</body></html>`
|
||||
out, _, err := Strip([]byte(in), Options{MobileReadable: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
// td:has(>img[usemap]) rule must be present so the whole nav column is hidden.
|
||||
if !strings.Contains(s, "td:has(>img[usemap])") {
|
||||
t.Error("td:has(>img[usemap]) rule missing from mobile CSS")
|
||||
}
|
||||
// td:has(>img[src*="trans_1x1"]) rule must be present for spacer column.
|
||||
if !strings.Contains(s, `td:has(>img[src*="trans_1x1"]`) {
|
||||
t.Error(`td:has(>img[src*="trans_1x1"]) spacer-column rule missing from mobile CSS`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileReadableSkipsExistingViewport(t *testing.T) {
|
||||
in := `<html><head><meta name="viewport" content="width=device-width"><title>x</title></head><body></body></html>`
|
||||
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{
|
||||
`<html><head><meta charset="utf-8"><title>x</title></head><body></body></html>`,
|
||||
`<html><head><meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1"><title>x</title></head><body></body></html>`,
|
||||
}
|
||||
for _, in := range cases {
|
||||
out, rep, err := Strip([]byte(in), Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.CharsetAdded {
|
||||
t.Errorf("CharsetAdded = true for a page that already declares one:\n%s", in)
|
||||
}
|
||||
if n := strings.Count(strings.ToLower(string(out)), "charset"); n != 1 {
|
||||
t.Errorf("charset count = %d, want 1:\n%s", n, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user