diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a3458e..37dc204 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/content/reference/release-notes.md b/docs/content/reference/release-notes.md
index 8177947..a92c26c 100644
--- a/docs/content/reference/release-notes.md
+++ b/docs/content/reference/release-notes.md
@@ -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`.
diff --git a/pack/pack_test.go b/pack/pack_test.go
index 4e0c8cf..9149ad2 100644
--- a/pack/pack_test.go
+++ b/pack/pack_test.go
@@ -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 {
@@ -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": "
1990s1990s
",
+ "wiki/2000s/index.html": "2000s2000s
",
+ })
+ 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
diff --git a/pack/zim.go b/pack/zim.go
index 074e08b..be7aaef 100644
--- a/pack/zim.go
+++ b/pack/zim.go
@@ -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)
@@ -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" {
@@ -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("\n")
+ b.WriteString(``)
+ b.WriteString(``)
+ b.WriteString("")
+ b.WriteString(htmlEscape(title))
+ b.WriteString("")
+ b.WriteString(``)
+ b.WriteString("")
+ b.WriteString("")
+ b.WriteString("")
+ b.WriteString(htmlEscape(title))
+ b.WriteString("
\n")
+ return []byte(b.String())
+}
+
+// htmlEscape escapes text for inclusion in HTML text and attribute values.
+func htmlEscape(s string) string {
+ replacer := strings.NewReplacer(
+ `&`, "&",
+ `<`, "<",
+ `>`, ">",
+ `"`, """,
+ )
+ return replacer.Replace(s)
+}
+
// htmlTitleOf reads the main page off disk and returns its , or "" if
// there is no main page or no title.
func htmlTitleOf(mirrorDir, mainURL string) string {