Skip to content

Add a synthetic ZIM landing page when root index is missing - #75

Open
SihanTeng wants to merge 2 commits into
tamnd:mainfrom
SihanTeng:fix/zim-synthetic-index
Open

Add a synthetic ZIM landing page when root index is missing#75
SihanTeng wants to merge 2 commits into
tamnd:mainfrom
SihanTeng:fix/zim-synthetic-index

Conversation

@SihanTeng

Copy link
Copy Markdown

Summary

  • When packing a host that has HTML pages but no root index.html, synthesize a simple directory-index landing page and use it as the ZIM main page.
  • Mirrors the browsable listing kage serve already offers for the same layout.

Why

Opening a multi-page host without a homepage in Kiwix/kage open jumped to an arbitrary first page (#62).

Test plan

  • TestSyntheticIndexWhenNoRootIndex in pack
  • go test -short -count=1 ./pack/

Closes #62

@SihanTeng
SihanTeng marked this pull request as ready for review July 31, 2026 05:06
# Conflicts:
#	CHANGELOG.md
#	docs/content/reference/release-notes.md

@tamnd tamnd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good diagnosis and the right shape for a fix. Synthesising the landing page at pack time is better than special casing Kiwix, and I like that it works for every reader.

Two things block it for me, both small:

  1. The trigger fires on single page mirrors, so kage clone --max-pages 1 && kage pack now opens a stub with one link on it. That is literally the command in issue #62 and it is the most common small workflow we have.
  2. Reassigning main silently changes the archive title metadata from the article's real title to the bare host name.

Third thing I would like while you are in the file: list the page titles rather than the raw paths. You already compute them a few lines up and throw them away, and for someone picking an article by name that is the whole point of the feature.

For context on why the mirror and the archive differ here: kage serve is a bare http.FileServer, so its index is a freebie from the standard library rather than something kage creates. Worth having kage own that page on both paths eventually.

Comment thread pack/zim.go
// 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 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

len(htmlPages) > 0 is too loose. Take the most common small workflow:

kage clone https://example.com/some/article --max-pages 1
kage pack example.com

One page, at some/article/index.html, no root index.html, so this branch fires and replaces a perfectly good main page with a generated index whose entire content is one link. The user now has to click through a stub to reach the only thing in the archive. The same happens to any mirror where every page lives under a common prefix, which is exactly what --scope-prefix produces.

Please make it len(htmlPages) > 1. With a single page pickMainPage is not being arbitrary, it is picking the only option there is.

Comment thread pack/zim.go
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"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line has a side effect that is not obvious from here. Further down, buildWriter does:

title := firstNonEmpty(opts.Title, htmlTitleOf(mirrorDir, main), host)

main is now "index.html", which does not exist on disk, so htmlTitleOf fails its os.ReadFile and returns "", and the title falls through to host.

Concretely: packing a single article without --title used to produce an archive titled "The 1990s - Wikipedia" and now produces one titled "en.wikipedia.org". That is the string Kiwix shows in its library list, so every archive built without an explicit title gets noticeably worse.

Fix is to capture the real main page before overriding it:

realMain := pickMainPage(htmlPages)
...
title := firstNonEmpty(opts.Title, htmlTitleOf(mirrorDir, realMain), host)

The new test passes Title: "Wiki picks" explicitly, so it never exercises this path. Please add a case that packs without a title and asserts the metadata.

Comment thread pack/zim.go
b.WriteString(`<li><a href="`)
b.WriteString(htmlEscape(p))
b.WriteString(`">`)
b.WriteString(htmlEscape(p))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reader gets a list of file paths here. But the walk in buildWriter already computes each page's real title with htmlTitleOfBytes(data) and then discards it.

Carrying a map[string]string or a small []pageEntry{path, title} out of the walk turns this from a file listing into a table of contents:

<li><a href="wiki/1990s/index.html">The 1990s - Wikipedia</a></li>

Sorting by title instead of path follows naturally. The doc comment on htmlTitleOfBytes says its whole purpose is "so a ZIM reader shows the page's real title instead of its URL path", and this would be the one place in the codebase doing the opposite. Given the point of the PR is a browsable landing page, I think this is the difference between solving #62 and half solving it.

Comment thread pack/zim.go
b.WriteString("<h1>")
b.WriteString(htmlEscape(title))
b.WriteString("</h1><ul>\n")
for _, p := range sorted {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bound on this loop. A mirror with 50,000 pages produces one <ul> with 50,000 <li> in it. It will compress fine inside the cluster but it is unusable as a landing page and slow to render in Kiwix on a phone.

Any of these is fine with me: cap at N with a "and 49,900 more" note, group by top level directory with <details>, or only synthesise below some threshold and fall back to the current behaviour above it. I would just rather not ship the unbounded version.

Comment thread pack/zim.go
}

// htmlEscape escapes text for inclusion in HTML text and attribute values.
func htmlEscape(s string) string {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates html.EscapeString, and pack/zim.go already imports golang.org/x/net/html. Please use the stdlib one.

Two reasons beyond taste: this output is HTML a user will open, so it is not the place for a hand rolled escaper, and strings.NewReplacer allocates on every call here.

Comment thread pack/zim.go
title = "Offline mirror"
}
var b strings.Builder
b.WriteString("<!doctype html>\n<html><head>")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing worth saying out loud: this generated page has a doctype, a charset, and a viewport meta, which makes it the only HTML kage produces that is not in quirks mode. Every page we save from Chrome loses its <!DOCTYPE html> because page.HTML() returns the outerHTML of <html> and the doctype is a sibling of that element.

Not your problem in this PR, I am fixing it separately under #16, but you did the right thing here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for websites lacking a homepage when generating a .zim

2 participants