Add the clone engine, CLI, tests, CI, and docs

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.
This commit is contained in:
Duc-Tam Nguyen
2026-06-14 18:22:25 +07:00
parent f7c5a9a1c6
commit e6afa91e09
48 changed files with 4697 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
---
title: "kage"
description: "kage (影, shadow) clones any website into a self-contained folder you can browse offline, with all the JavaScript stripped out. Render in headless Chrome, remove every script, localise the CSS, images, and fonts, from one pure-Go binary."
heroTitle: "A website, frozen as a shadow"
heroLead: "kage renders every page in headless Chrome, snapshots the final DOM, removes every script and event handler, and downloads and rewrites the CSS, images, and fonts. The result looks like the live site but runs no code: a plain folder of .html files you can open straight from disk."
heroPrimaryURL: "/getting-started/quick-start/"
heroPrimaryText: "Get started"
---
Saving a page with "Save As" gives you a copy that still phones home, still runs
analytics, and often renders blank because the markup is built by JavaScript at
runtime. kage (影, "shadow") takes the opposite approach: it drives a real
browser, captures the page the way a human would have seen it, then makes it
inert.
```bash
kage clone example.com
kage serve kage-out/example.com
```
## What it does
- **Renders first, saves second.** Each page goes through real headless Chrome,
so a page whose content is assembled by JavaScript is captured fully, not as
an empty shell.
- **Strips every script.** Once the DOM is captured, kage removes all `<script>`
tags, every `on*` event handler, and any `javascript:` URL. The saved page
makes no network calls and runs no code.
- **Keeps the layout.** Stylesheets, images, fonts, and media are downloaded and
rewritten to relative local paths, so the offline copy looks like the original.
- **Stays browsable.** In-scope links are rewritten to point at the other saved
pages, so you can click around the mirror exactly as you would the live site.
## Where to go next
- New here? Start with the [introduction](/getting-started/introduction/), then
the [quick start](/getting-started/quick-start/).
- Want to install it? See [installation](/getting-started/installation/).
- Looking for a specific task? The [guides](/guides/) cover scoping a crawl,
serving a mirror, and resuming an interrupted run.
- Need every flag? The [CLI reference](/reference/cli/) is the full surface.
+11
View File
@@ -0,0 +1,11 @@
---
title: "Getting started"
linkTitle: "Getting started"
description: "Install kage and clone your first site into a browsable offline folder in under a minute."
weight: 10
featured: true
---
Three short pages: how kage thinks about cloning a site (render, strip, localise),
how to install the binary and point it at a browser, and a guided first run that
ends with a real offline mirror you can click through.
@@ -0,0 +1,63 @@
---
title: "Installation"
description: "Install kage from Go, Homebrew, a release archive, a Linux package, or the container image, and point it at a browser."
weight: 20
---
kage is a single binary. Pick whichever channel suits you.
## Go
```bash
go install github.com/tamnd/kage/cmd/kage@latest
```
## Homebrew
```bash
brew install tamnd/tap/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`.
```bash
# Debian/Ubuntu
sudo dpkg -i kage_*_linux_amd64.deb
# Fedora/RHEL
sudo rpm -i kage_*_linux_amd64.rpm
```
## Container
The image bundles Chromium, so it needs nothing else:
```bash
docker run -v "$PWD/out:/out" ghcr.io/tamnd/kage clone example.com
```
The mirror lands in `./out/example.com/` on your host.
## You need a browser
kage drives a real Chrome to render pages. Outside the container image, it needs
Chrome or Chromium available on the machine. It looks for a system install
automatically (Google Chrome on macOS and Windows, `google-chrome`/`chromium` on
Linux). To use a specific binary:
```bash
kage clone example.com --chrome /path/to/chromium
# or
export KAGE_CHROME=/path/to/chromium
```
If no browser is found, kage's launcher can download a private copy of Chromium
on first use.
Next: [the quick start](/getting-started/quick-start/).
@@ -0,0 +1,53 @@
---
title: "Introduction"
description: "Why kage renders before it saves, and what it means to strip the JavaScript out of a clone."
weight: 10
---
A normal website is not a document; it is a program. The HTML the server sends
is often a near-empty shell, and the page you actually see is assembled in your
browser by JavaScript: fetching data, building the DOM, wiring up handlers. That
is why "Save As" so often fails. You get the shell, not the page, and whatever
you do get still runs trackers and phones home when you open it.
kage treats a clone as three steps in order.
## 1. Render
Every page is loaded in a real headless Chrome through the DevTools protocol.
kage navigates to the URL, waits for the network to go quiet, optionally scrolls
to trigger lazy-loaded images, and then serialises the **final** DOM, the markup
that exists after the page's JavaScript has finished building it. This is the
same thing you would see if you opened the page and chose "Inspect".
## 2. Strip
From that captured DOM, kage removes everything executable:
- every `<script>` tag, inline or external;
- every `on*` event handler attribute (`onclick`, `onload`, and the rest);
- every `javascript:` URL;
- `<meta http-equiv="refresh">` redirects and dead resource hints like
`<link rel="preload" as="script">`.
What remains is inert. The saved page makes no network calls, runs no code, and
tracks nothing.
## 3. Localise
A page with no working CSS or images is not much of a clone, so kage keeps the
parts that define how it looks. It downloads every stylesheet, image, font, and
media file, rewrites the references in the HTML and inside the CSS (`url()` and
`@import`) to relative local paths, and rewrites in-scope page links to point at
the other saved pages. The mirror is fully self-contained: you can move the
folder anywhere, open it with no network, and click around.
## The shape of a clone
kage crawls breadth-first from a seed URL, staying within the seed's host (and
optionally its subdomains). It is polite by default: it honours `robots.txt` and
seeds itself from `sitemap.xml`. Output lands in `kage-out/<host>/`, with pages
as `<path>/index.html` and assets under a reserved `_kage/` directory alongside
the crawl state that powers `--resume`.
Next: [install kage](/getting-started/installation/).
@@ -0,0 +1,68 @@
---
title: "Quick start"
description: "From an empty terminal to a self-contained offline mirror you can click through."
weight: 30
---
This walks the core loop: clone a small site, look at what landed on disk, and
serve it back so links and assets resolve the way they would on a real host.
## 1. Clone a site
```bash
kage clone example.com
```
kage launches headless Chrome, renders the home page, strips its scripts, and
follows in-scope links breadth-first. A live counter shows pages, assets, and
errors as it goes; the final summary tells you where the mirror landed.
```
kage cloning https://example.com
done kage-out/example.com
pages 12 assets 38
open kage serve kage-out/example.com
```
## 2. Look at what landed
```bash
ls kage-out/example.com
```
```
index.html # the home page, scripts stripped
about/index.html # /about
_kage/ # localised assets and crawl state
```
Open `index.html` directly in a browser and it renders offline, with no network.
Grep it and you will find no `<script>`, no `onclick`, no `javascript:`.
## 3. Serve it back
Opening files directly works, but some sites use root-relative links. `kage
serve` runs a local static server so everything resolves exactly as it would
live:
```bash
kage serve kage-out/example.com
# open http://127.0.0.1:8800
```
## 4. Scope a bigger crawl
For a large site, bound the crawl so it does not run away:
```bash
# Just the docs section, three levels deep, at most 200 pages
kage clone example.com --scope-prefix /docs --max-depth 3 --max-pages 200
```
If you stop a run with Ctrl-C, kage saves its state. Run the same command again
and it resumes, skipping the pages it already wrote.
## Where to go next
- The [guides](/guides/) cover scoping, serving, and resuming in depth.
- The [CLI reference](/reference/cli/) lists every flag.
+11
View File
@@ -0,0 +1,11 @@
---
title: "Guides"
linkTitle: "Guides"
description: "Task-oriented walkthroughs for the things people actually do with kage: scoping a crawl, serving a mirror, and resuming a run."
weight: 20
featured: true
---
Each guide is built around a job rather than a flag: keeping a crawl inside the
lines, viewing what you cloned, and picking up an interrupted run. They assume
you have worked through the [quick start](/getting-started/quick-start/).
+48
View File
@@ -0,0 +1,48 @@
---
title: "Resuming a run"
description: "Pick up an interrupted clone where it left off, and start fresh when you want to."
weight: 30
---
Cloning a large site can take a while, and runs get interrupted: you press
Ctrl-C, your laptop sleeps, the network drops. kage is built to pick up where it
left off.
## How resume works
As it writes each page, kage records it in a small state file inside the mirror,
at `<host>/_kage/state.json`. When a run ends, for any reason, that file holds
the set of pages already written. Resume is **on by default**: the next time you
run the same clone, kage loads the state and skips every page it already wrote,
re-crawling only what is left.
```bash
kage clone example.com
# ... press Ctrl-C partway through ...
# interrupted; resume state saved (rerun to continue)
kage clone example.com
# resume: 137 pages already done
```
Ctrl-C is a clean stop: kage cancels in-flight renders, flushes the state file,
and exits. You will not lose the pages already written.
## Start fresh
To ignore any previous run and rebuild the mirror from scratch, delete the
existing host folder first with `--force`:
```bash
kage clone example.com --force
```
This removes `kage-out/example.com/` before crawling, so nothing from a prior run
carries over.
To run without reading or writing any resume state at all, for a strictly
one-shot clone, use `--no-resume`:
```bash
kage clone example.com --no-resume
```
+73
View File
@@ -0,0 +1,73 @@
---
title: "Scoping a crawl"
description: "Keep a clone inside the lines with depth, page, prefix, subdomain, and exclude controls."
weight: 10
---
By default kage crawls every in-scope page it can reach from the seed, staying on
the seed's host. On a large site that can be a lot of pages. These flags bound
the crawl.
## Limit by count and depth
```bash
# Stop after 200 pages
kage clone example.com --max-pages 200
# Only follow links three hops from the seed
kage clone example.com --max-depth 3
```
`--max-depth 0` (the default) means unlimited depth; `--max-pages 0` means
unlimited pages. Combine them to put a hard ceiling on a run.
## Limit by path
To clone just one section of a site, restrict the crawl to a path prefix:
```bash
kage clone example.com --scope-prefix /docs
```
Only pages whose path starts with `/docs` are followed. Assets are still fetched
from wherever the page references them, so the section renders correctly.
To skip parts of a site, exclude path prefixes (repeatable):
```bash
kage clone example.com --exclude /archive --exclude /tags
```
## Subdomains
By default a clone stays on the exact seed host. To treat subdomains of the seed
as in scope, add `--subdomains`:
```bash
kage clone example.com --subdomains
```
Now `blog.example.com` and `docs.example.com` are crawled too, each landing
under its own host directory inside the mirror.
## Politeness
kage honours `robots.txt` by default and seeds itself from `sitemap.xml`. If you
are cloning a site you control, or you have a reason to ignore the robots rules,
you can turn them off, but do so responsibly:
```bash
kage clone example.com --no-robots --no-sitemap
```
## Lazy-loaded media
Sites that load images as you scroll will only have their above-the-fold images
captured unless you tell kage to scroll each page:
```bash
kage clone example.com --scroll
```
This makes each render a little slower but captures media that only loads on
view.
+48
View File
@@ -0,0 +1,48 @@
---
title: "Serving a mirror"
description: "View a cloned folder the way it would render on a real host, with kage serve."
weight: 20
---
A clone is a plain folder of files, so the simplest way to view it is to open an
`.html` file in your browser. That works for many sites. But some pages use
root-relative URLs (`/style.css`, `/img/logo.png`), which only resolve correctly
when served from the root of a host. `kage serve` gives you that root.
## Serve a clone
```bash
kage serve kage-out/example.com
```
```
kage serve /…/kage-out/example.com
open http://127.0.0.1:8800
press Ctrl-C to stop
```
Open the printed URL and click around the mirror exactly as you would the live
site. Every in-scope link kage rewrote points at another saved page; every asset
resolves to its localised copy.
## Choose an address
By default kage serves on `127.0.0.1:8800`. Change it with `--addr`:
```bash
# A different port
kage serve kage-out/example.com --addr 127.0.0.1:9000
# Reachable from other machines on your network (be deliberate about this)
kage serve kage-out/example.com --addr 0.0.0.0:8800
```
## Serve the current directory
With no argument, `kage serve` serves the current directory, which is handy from
inside an output folder:
```bash
cd kage-out/example.com
kage serve
```
+11
View File
@@ -0,0 +1,11 @@
---
title: "Reference"
linkTitle: "Reference"
description: "The complete kage surface: every command, every flag, and every environment variable."
weight: 30
featured: true
---
The exhaustive reference. The [CLI](/reference/cli/) page lists every command and
flag; the [configuration](/reference/configuration/) page covers environment
variables and the output layout on disk.
+85
View File
@@ -0,0 +1,85 @@
---
title: "CLI reference"
description: "Every kage command and flag."
weight: 10
---
```
kage [command] [flags]
```
Two commands: `clone` fetches a site into an offline folder, `serve` previews
one. Run `kage <command> --help` for the canonical, up-to-date list.
## kage clone
```
kage clone <url> [flags]
```
Renders each page in headless Chrome, strips all JavaScript, localises CSS,
images, and fonts, and writes a browsable mirror to `<out>/<host>/`.
### Output
| Flag | Default | Meaning |
|------|---------|---------|
| `-o, --out` | `kage-out` | Output root; the mirror lands in `<out>/<host>/` |
| `--reserved` | `_kage` | Reserved directory name for assets and crawl state |
| `-f, --force` | `false` | Delete any existing mirror for the host before crawling |
| `--no-resume` | `false` | Do not read or write resume state |
### Scope
| Flag | Default | Meaning |
|------|---------|---------|
| `-p, --max-pages` | `0` | Stop after N pages (0 = unlimited) |
| `-d, --max-depth` | `0` | Link-follow depth cap (0 = unlimited) |
| `--scope-prefix` | | Only crawl pages whose path starts with this prefix |
| `--subdomains` | `false` | Treat subdomains of the seed host as in scope |
| `--exclude` | | Path prefixes to skip (repeatable) |
| `--traversal` | `bfs` | Frontier order: `bfs` or `dfs` |
### Politeness
| Flag | Default | Meaning |
|------|---------|---------|
| `--no-robots` | `false` | Ignore `robots.txt` |
| `--no-sitemap` | `false` | Do not seed URLs from `sitemap.xml` |
| `--user-agent` | Chrome UA | User-Agent for asset and robots fetches |
### Rendering
| Flag | Default | Meaning |
|------|---------|---------|
| `--scroll` | `false` | Auto-scroll each page to trigger lazy loading |
| `--settle` | `1.5s` | Network-idle quiet period before snapshotting the DOM |
| `--render-timeout` | `30s` | Hard cap per page render |
| `--headful` | `false` | Run Chrome with a visible window (debugging) |
| `--chrome` | | Path to the Chrome/Chromium binary |
| `--control-url` | | Attach to an existing Chrome DevTools endpoint |
| `--keep-noscript` | `false` | Unwrap `<noscript>` content instead of dropping it |
### Concurrency and limits
| Flag | Default | Meaning |
|------|---------|---------|
| `--workers` | `4` | Concurrent page render workers |
| `--asset-workers` | `8` | Concurrent asset download workers |
| `--browser-pages` | `4` | Chrome page-pool size |
| `--max-asset-mb` | `25` | Skip assets larger than N MB |
| `--timeout` | `30s` | Per-request timeout |
| `-q, --quiet` | `false` | Suppress per-page progress lines |
## kage serve
```
kage serve [dir] [flags]
```
Runs a local static file server over a cloned folder. With no `dir`, serves the
current directory.
| Flag | Default | Meaning |
|------|---------|---------|
| `-a, --addr` | `127.0.0.1:8800` | Address to listen on |
+54
View File
@@ -0,0 +1,54 @@
---
title: "Configuration"
description: "Environment variables kage reads, and the layout of a cloned mirror on disk."
weight: 20
---
kage is configured almost entirely through command-line flags (see the
[CLI reference](/reference/cli/)). It reads a couple of environment variables for
locating the browser.
## Environment variables
| Variable | Meaning |
|----------|---------|
| `KAGE_CHROME` | Path to the Chrome/Chromium binary. Takes precedence over autodetection. Equivalent to `--chrome`. |
| `CHROME_BIN` | Fallback Chrome path, read if `KAGE_CHROME` is unset. |
If neither is set and no system Chrome is found in the usual install locations,
kage's launcher can download a private copy of Chromium on first use.
## Output layout
A clone of `example.com` lands under `kage-out/example.com/`:
```
kage-out/example.com/
├── index.html # the home page (/), scripts stripped
├── about/index.html # /about
├── blog/
│ ├── index.html # /blog
│ └── a-post/index.html # /blog/a-post
├── _kage/ # reserved directory
│ ├── example.com/
│ │ ├── site.css # localised stylesheet, url() rewritten
│ │ ├── logo.png
│ │ └── fonts/body.woff2
│ ├── cdn.example.com/ # assets from other hosts, by host
│ └── state.json # visited set, for --resume
└── ...
```
Key points:
- **Pages become directories.** A page at `/about` is written as
`about/index.html`, so a link to `/about` resolves to a real file when served.
- **Assets live under the reserved directory.** Everything kage downloads, CSS,
images, fonts, media, goes under `_kage/<asset-host>/`, mirroring the path it
had on its origin. Cross-origin assets are grouped by their own host.
- **Query strings are folded into the filename.** An asset like
`style.css?v=3` is saved with a short hash suffix so two versions never
collide.
- **State lives in the mirror.** `_kage/state.json` records every page written,
which is what makes `--resume` able to skip completed work. Rename the reserved
directory with `--reserved` if `_kage` would clash with a real path on the site.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="3" fill="#0b6e4f"/><path fill="#f6f8fa" d="M7 2.5a4.5 4.5 0 1 0 2.62 8.15l2.36 2.37a.75.75 0 1 0 1.06-1.06l-2.37-2.36A4.5 4.5 0 0 0 7 2.5Zm-3 4.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z"/></svg>

After

Width:  |  Height:  |  Size: 300 B

+10
View File
@@ -0,0 +1,10 @@
title = "kage"
baseURL = "https://kage.tamnd.com/"
description = "kage (影, shadow) clones any website into a self-contained folder you can browse offline, with all the JavaScript stripped out. It renders every page in headless Chrome, removes every script, and localises the CSS, images, and fonts."
contentDir = "content"
outputDir = "public"
theme = "tago-doks"
syntaxHighlight = "true"
[params]
github = "https://github.com/tamnd/kage"