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": "1990s

1990s

", + "wiki/2000s/index.html": "2000s

2000s

", + }) + 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 {