e6afa91e09
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.
148 lines
3.9 KiB
Go
148 lines
3.9 KiB
Go
package asset
|
|
|
|
import (
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/tamnd/kage/urlx"
|
|
"golang.org/x/net/html"
|
|
"golang.org/x/net/html/atom"
|
|
)
|
|
|
|
// stylesheetRels are <link rel> values whose href kage downloads as an asset.
|
|
var stylesheetRels = map[string]bool{
|
|
"stylesheet": true, "icon": true, "shortcut icon": true,
|
|
"apple-touch-icon": true, "apple-touch-icon-precomposed": true,
|
|
"mask-icon": true, "manifest": true, "preload": true, "prefetch": true,
|
|
}
|
|
|
|
// RewriteHTML walks the parsed document and rewrites every resource and link
|
|
// reference through sink, resolving relative URLs against base. It mutates the
|
|
// tree in place; the caller renders it afterwards. References kage cannot handle
|
|
// (data:, mailto:, fragment-only, …) are left untouched.
|
|
func RewriteHTML(root *html.Node, base *url.URL, sink RefSink) {
|
|
var walk func(n *html.Node)
|
|
walk = func(n *html.Node) {
|
|
if n.Type == html.ElementNode {
|
|
rewriteElement(n, base, sink)
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(root)
|
|
}
|
|
|
|
func rewriteElement(n *html.Node, base *url.URL, sink RefSink) {
|
|
switch n.DataAtom {
|
|
case atom.A, atom.Area:
|
|
rewriteAttr(n, "href", base, sink, pageOrAsset)
|
|
case atom.Iframe, atom.Frame:
|
|
rewriteAttr(n, "src", base, sink, pageOrAsset)
|
|
case atom.Link:
|
|
rel := strings.ToLower(strings.TrimSpace(attrVal(n, "rel")))
|
|
if stylesheetRels[rel] {
|
|
rewriteAttr(n, "href", base, sink, alwaysAsset)
|
|
}
|
|
case atom.Img:
|
|
rewriteAttr(n, "src", base, sink, alwaysAsset)
|
|
rewriteSrcset(n, base, sink)
|
|
case atom.Source:
|
|
rewriteAttr(n, "src", base, sink, alwaysAsset)
|
|
rewriteSrcset(n, base, sink)
|
|
case atom.Video:
|
|
rewriteAttr(n, "src", base, sink, alwaysAsset)
|
|
rewriteAttr(n, "poster", base, sink, alwaysAsset)
|
|
case atom.Audio, atom.Track, atom.Embed:
|
|
rewriteAttr(n, "src", base, sink, alwaysAsset)
|
|
case atom.Object:
|
|
rewriteAttr(n, "data", base, sink, alwaysAsset)
|
|
case atom.Style:
|
|
rewriteStyleText(n, base, sink)
|
|
}
|
|
// Any element may carry an inline style="" with url() references.
|
|
rewriteInlineStyle(n, base, sink)
|
|
}
|
|
|
|
// kindFunc decides whether a normalized URL is a page or an asset.
|
|
type kindFunc func(u *url.URL) urlx.Kind
|
|
|
|
func alwaysAsset(*url.URL) urlx.Kind { return urlx.Asset }
|
|
|
|
func pageOrAsset(u *url.URL) urlx.Kind {
|
|
if urlx.LikelyPage(u) {
|
|
return urlx.Page
|
|
}
|
|
return urlx.Asset
|
|
}
|
|
|
|
func rewriteAttr(n *html.Node, key string, base *url.URL, sink RefSink, kf kindFunc) {
|
|
for i := range n.Attr {
|
|
if !strings.EqualFold(n.Attr[i].Key, key) {
|
|
continue
|
|
}
|
|
u, err := urlx.Normalize(base, n.Attr[i].Val)
|
|
if err != nil {
|
|
return
|
|
}
|
|
n.Attr[i].Val = sink(u, kf(u))
|
|
return
|
|
}
|
|
}
|
|
|
|
// rewriteSrcset rewrites each candidate URL in a srcset attribute, keeping the
|
|
// width/density descriptors intact.
|
|
func rewriteSrcset(n *html.Node, base *url.URL, sink RefSink) {
|
|
for i := range n.Attr {
|
|
if !strings.EqualFold(n.Attr[i].Key, "srcset") {
|
|
continue
|
|
}
|
|
n.Attr[i].Val = rewriteSrcsetValue(n.Attr[i].Val, base, sink)
|
|
return
|
|
}
|
|
}
|
|
|
|
func rewriteSrcsetValue(val string, base *url.URL, sink RefSink) string {
|
|
parts := strings.Split(val, ",")
|
|
for i, p := range parts {
|
|
fields := strings.Fields(strings.TrimSpace(p))
|
|
if len(fields) == 0 {
|
|
continue
|
|
}
|
|
u, err := urlx.Normalize(base, fields[0])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
fields[0] = sink(u, urlx.Asset)
|
|
parts[i] = strings.Join(fields, " ")
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func rewriteInlineStyle(n *html.Node, base *url.URL, sink RefSink) {
|
|
for i := range n.Attr {
|
|
if !strings.EqualFold(n.Attr[i].Key, "style") {
|
|
continue
|
|
}
|
|
n.Attr[i].Val = string(RewriteCSS([]byte(n.Attr[i].Val), base, sink))
|
|
return
|
|
}
|
|
}
|
|
|
|
func rewriteStyleText(n *html.Node, base *url.URL, sink RefSink) {
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if c.Type == html.TextNode {
|
|
c.Data = string(RewriteCSS([]byte(c.Data), base, sink))
|
|
}
|
|
}
|
|
}
|
|
|
|
func attrVal(n *html.Node, key string) string {
|
|
for _, a := range n.Attr {
|
|
if strings.EqualFold(a.Key, key) {
|
|
return a.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|