Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to kage are recorded here. The format follows

## [Unreleased]

### Fixed

- Packing a host that has HTML but no root `index.html` synthesises a directory-index landing page so Kiwix and `kage open` no longer jump to an arbitrary first page ([#62](https://github.com/tamnd/kage/issues/62)).

## [0.3.11] - 2026-08-01

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions docs/content/reference/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ 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.

## Unreleased

- **ZIM packs get a usable landing page.** Hosts without a root `index.html` receive a synthetic directory index instead of opening an arbitrary first page ([#62](https://github.com/tamnd/kage/issues/62)).

## v0.3.11

- **`go install ...@latest` works again.** The v0.3.9 antivirus fix replaced Rod's leakless dependency with a local stub. That kept the flagged helper out of `kage.exe`, but Go refuses versioned installation of a module containing a dependency-changing `replace` directive ([#72](https://github.com/tamnd/kage/issues/72)). Windows now launches Chrome through a small platform-specific launcher that never imports leakless. Other platforms keep Rod's launcher, the Windows binary remains free of the flagged helper, and the module no longer needs `replace`.
Expand Down
57 changes: 57 additions & 0 deletions pack/pack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,27 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/tamnd/kage/urlx"
"github.com/tamnd/kage/zim"
)

// writeFiles creates rel→content files under root.
func writeFiles(t *testing.T, root string, files map[string]string) {
t.Helper()
for rel, body := range files {
p := filepath.Join(root, 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)
}
}
}

// writeMirror lays down a small kage-style mirror under a temp dir and returns
// the host dir.
func writeMirror(t *testing.T) string {
Expand Down Expand Up @@ -236,6 +251,48 @@ func TestPickMainPage(t *testing.T) {
}
}

// TestSyntheticIndexWhenNoRootIndex covers issue #62: packing a host that has
// HTML pages but no root index.html must still open on a browsable landing page.
func TestSyntheticIndexWhenNoRootIndex(t *testing.T) {
dir := t.TempDir()
host := filepath.Join(dir, "en.wikipedia.org")
writeFiles(t, host, map[string]string{
"wiki/1990s/index.html": "<!doctype html><title>1990s</title><h1>1990s</h1>",
"wiki/2000s/index.html": "<!doctype html><title>2000s</title><h1>2000s</h1>",
})
out := filepath.Join(dir, "wiki.zim")
if _, _, err := BuildZIM(host, ZIMOptions{Out: out, Date: "2026-07-31", Title: "Wiki picks"}); err != nil {
t.Fatalf("BuildZIM: %v", err)
}
r, err := zim.Open(out)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer func() { _ = r.Close() }()
mp, err := r.MainPage()
if err != nil {
t.Fatalf("MainPage: %v", err)
}
body := string(mp.Data)
if !strings.Contains(body, "wiki/1990s/index.html") || !strings.Contains(body, "wiki/2000s/index.html") {
t.Errorf("synthetic index missing page links:\n%s", body)
}
if !strings.Contains(body, "Wiki picks") {
t.Errorf("synthetic index missing title:\n%s", body)
}
// Opening "/" via the handler must redirect to the synthetic index.
h := Handler(r)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("GET / status = %d, want 302; body %q", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/index.html" {
t.Errorf("GET / Location = %q, want /index.html", loc)
}
}

// TestBinaryTrailerRoundTrip exercises the BuildBinary append contract and the
// trailer it leaves, without depending on os.Executable: it appends a ZIM to a
// fake base, reads the trailer back the way Embedded does, and serves the
Expand Down
61 changes: 60 additions & 1 deletion pack/zim.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,16 @@ func buildWriter(mirrorDir string, opts ZIMOptions) (*zim.Writer, *clusterCache,
}

main := pickMainPage(htmlPages)
// When the mirror has HTML but no root index.html (e.g. two Wikipedia
// articles cloned into the same host), kage serve still offers a directory
// listing. A ZIM has no such listing, so synthesise an index that links
// every HTML page and use it as the main page (issue #62).
if main != "index.html" && len(htmlPages) > 0 {
listing := syntheticIndexHTML(htmlPages, firstNonEmpty(opts.Title, filepath.Base(mirrorDir)))
w.AddContent(zim.NamespaceContent, "index.html", firstNonEmpty(opts.Title, filepath.Base(mirrorDir)), "text/html", listing)
counts["text/html"]++
main = "index.html"
}
if main != "" {
w.SetMainPage(zim.NamespaceContent, main)
w.AddRedirect(zim.NamespaceWellKnown, "mainPage", "", zim.NamespaceContent, main)
Expand Down Expand Up @@ -218,7 +228,9 @@ func buildWriter(mirrorDir string, opts ZIMOptions) (*zim.Writer, *clusterCache,

// pickMainPage chooses the archive's entry point: the root index if present,
// else the shallowest HTML page, ties broken lexicographically for determinism.
// It returns "" when the mirror has no HTML at all.
// It returns "" when the mirror has no HTML at all. Callers that want a
// navigable landing page when index.html is missing should synthesise one
// (see buildWriter and syntheticIndexHTML).
func pickMainPage(htmlPages []string) string {
for _, p := range htmlPages {
if p == "index.html" {
Expand All @@ -239,6 +251,53 @@ func pickMainPage(htmlPages []string) string {
return ""
}

// syntheticIndexHTML builds a minimal, script-free landing page that lists
// every HTML path in the mirror. Used when packing a host that has no root
// index.html so Kiwix and kage open land on a browsable index (issue #62).
func syntheticIndexHTML(htmlPages []string, title string) []byte {
sorted := append([]string(nil), htmlPages...)
sort.Strings(sorted)
if title == "" {
title = "Offline mirror"
}
var b strings.Builder
b.WriteString("<!doctype html>\n<html><head>")
b.WriteString(`<meta charset="utf-8">`)
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1">`)
b.WriteString("<title>")
b.WriteString(htmlEscape(title))
b.WriteString("</title>")
b.WriteString(`<style>body{font-family:system-ui,sans-serif;margin:1.5rem;line-height:1.5}h1{font-size:1.25rem}ul{padding-left:1.25rem}a{color:#06c}</style>`)
b.WriteString("</head><body>")
b.WriteString("<!-- generated by kage: directory index for a mirror with no root index.html -->")
b.WriteString("<h1>")
b.WriteString(htmlEscape(title))
b.WriteString("</h1><ul>\n")
for _, p := range sorted {
if p == "index.html" {
continue
}
b.WriteString(`<li><a href="`)
b.WriteString(htmlEscape(p))
b.WriteString(`">`)
b.WriteString(htmlEscape(p))
b.WriteString("</a></li>\n")
}
b.WriteString("</ul></body></html>\n")
return []byte(b.String())
}

// htmlEscape escapes text for inclusion in HTML text and attribute values.
func htmlEscape(s string) string {
replacer := strings.NewReplacer(
`&`, "&amp;",
`<`, "&lt;",
`>`, "&gt;",
`"`, "&quot;",
)
return replacer.Replace(s)
}

// htmlTitleOf reads the main page off disk and returns its <title>, or "" if
// there is no main page or no title.
func htmlTitleOf(mirrorDir, mainURL string) string {
Expand Down