diff --git a/cli/open.go b/cli/open.go new file mode 100644 index 0000000..9c4889a --- /dev/null +++ b/cli/open.go @@ -0,0 +1,63 @@ +package cli + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + + "github.com/spf13/cobra" + + "github.com/tamnd/kage/pack" + "github.com/tamnd/kage/zim" +) + +func newOpenCmd() *cobra.Command { + var addr string + var openBrowser bool + cmd := &cobra.Command{ + Use: "open ", + Short: "Serve a ZIM archive in your browser for offline reading", + Long: "open serves a packed ZIM file over a local HTTP server so you can browse the\n" + + "site exactly as it was cloned. It is the read side of kage pack --format zim.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runOpen(cmd.Context(), args[0], addr, openBrowser) + }, + } + cmd.Flags().StringVarP(&addr, "addr", "a", "127.0.0.1:8800", "address to listen on") + cmd.Flags().BoolVar(&openBrowser, "open", true, "open the default browser") + return cmd +} + +func runOpen(ctx context.Context, path, addr string, openBrowser bool) error { + r, err := zim.Open(path) + if err != nil { + return fmt.Errorf("cannot open %q: %w", path, err) + } + defer r.Close() + + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("cannot listen on %s: %w", addr, err) + } + url := "http://" + ln.Addr().String() + + fmt.Fprintln(os.Stderr, styleTitle.Render("kage open")+" "+styleDim.Render(path)) + fmt.Fprintln(os.Stderr, " open "+styleAccent.Render(url)) + fmt.Fprintln(os.Stderr, styleDim.Render(" press Ctrl-C to stop")) + if openBrowser { + _ = pack.OpenInBrowser(url) + } + + srv := &http.Server{Handler: pack.Handler(r)} + go func() { + <-ctx.Done() + _ = srv.Close() + }() + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + return err + } + return nil +} diff --git a/cli/pack.go b/cli/pack.go new file mode 100644 index 0000000..735352f --- /dev/null +++ b/cli/pack.go @@ -0,0 +1,168 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/tamnd/kage/clone" + "github.com/tamnd/kage/pack" +) + +// packFlags holds the parsed flags for one invocation of kage pack. +type packFlags struct { + format string + out string + base string + noCompress bool + title string + description string + language string + date string +} + +func newPackCmd() *cobra.Command { + f := &packFlags{} + cmd := &cobra.Command{ + Use: "pack ", + Short: "Pack a cloned mirror into a ZIM file or a self-contained viewer", + Long: "pack turns a cloned folder into one distributable file. With --format zim\n" + + "it writes an open ZIM archive (the format Kiwix uses) that kage open or any\n" + + "ZIM reader can browse. With --format binary it appends that archive to a copy\n" + + "of kage, producing a single executable that serves the site offline when run.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPack(args[0], f) + }, + } + fs := cmd.Flags() + fs.StringVar(&f.format, "format", "zim", "output format: zim or binary") + fs.StringVarP(&f.out, "out", "o", "", "output path (default per format)") + fs.StringVar(&f.base, "base", "", "base kage binary for --format binary (default this kage)") + fs.BoolVar(&f.noCompress, "no-compress", false, "store every cluster raw, no zstd") + fs.StringVar(&f.title, "title", "", "archive title (default the main page's )") + fs.StringVar(&f.description, "description", "", "archive description") + fs.StringVar(&f.language, "language", "eng", "archive language code") + fs.StringVar(&f.date, "date", time.Now().UTC().Format("2006-01-02"), "archive date (YYYY-MM-DD)") + return cmd +} + +func runPack(mirrorArg string, f *packFlags) error { + dir := resolveMirror(mirrorArg) + zopts := pack.ZIMOptions{ + Out: f.out, + NoCompress: f.noCompress, + Title: f.title, + Description: f.description, + Language: f.language, + Date: f.date, + Version: Version, + } + + switch f.format { + case "zim": + out, size, err := pack.BuildZIM(dir, zopts) + if err != nil { + return err + } + printPackResult(out, size) + fmt.Fprintf(os.Stderr, " open %s\n", styleAccent.Render("kage open "+out)) + return nil + + case "binary": + zbytes, err := pack.BuildZIMBytes(dir, zopts) + if err != nil { + return err + } + out := f.out + if out == "" { + out = defaultBinaryName(dir, f.base) + } + path, size, err := pack.BuildBinary(zbytes, pack.BinaryOptions{Out: out, Base: f.base}) + if err != nil { + return err + } + printPackResult(path, size) + printRunHint(path) + return nil + + default: + return fmt.Errorf("unknown --format %q (want zim or binary)", f.format) + } +} + +// resolveMirror accepts either a path to a mirror dir or a bare host. A bare +// host that is not a directory in the working dir is resolved against the +// default out dir, so "kage pack paulgraham.com" works right after a clone. +func resolveMirror(arg string) string { + if info, err := os.Stat(arg); err == nil && info.IsDir() { + return arg + } + candidate := filepath.Join(clone.DefaultOutDir(), arg) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate + } + return arg +} + +// defaultBinaryName derives a clean program name from the mirror's host: strip a +// trailing dot-suffix (paulgraham.com -> paulgraham), and append .exe when the +// target is Windows. The target is the running OS unless --base names a binary +// that looks like it was built for Windows. +func defaultBinaryName(dir, base string) string { + host := filepath.Base(dir) + name := host + if i := strings.IndexByte(host, '.'); i > 0 { + name = host[:i] + } + if windowsTarget(base) { + name += ".exe" + } + return name +} + +func windowsTarget(base string) bool { + if base == "" { + return runtime.GOOS == "windows" + } + return strings.HasSuffix(strings.ToLower(base), ".exe") +} + +func printPackResult(path string, size int64) { + fmt.Fprintln(os.Stderr, styleOK.Render("packed")+" "+styleTitle.Render(path)) + fmt.Fprintf(os.Stderr, " %s %s\n", styleAccent.Render("size"), humanBytes(size)) +} + +func printRunHint(path string) { + rel := path + if !strings.ContainsAny(path, "/\\") { + rel = "./" + path + } + if windowsTarget(path) && runtime.GOOS != "windows" { + fmt.Fprintf(os.Stderr, " this is a Windows viewer; run %s on Windows\n", styleAccent.Render(filepath.Base(path))) + return + } + fmt.Fprintf(os.Stderr, " run %s to view the site offline\n", styleAccent.Render(rel)) + if runtime.GOOS == "darwin" { + fmt.Fprintln(os.Stderr, styleDim.Render(" (macOS may quarantine it: xattr -d com.apple.quarantine "+rel+")")) + } +} + +// humanBytes renders a byte count in B, KiB, MiB, or GiB. +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/cli/root.go b/cli/root.go index dec3a5a..c5a8039 100644 --- a/cli/root.go +++ b/cli/root.go @@ -7,15 +7,28 @@ package cli import ( "context" "fmt" + "io" + "net" + "net/http" + "os" "github.com/charmbracelet/fang" "github.com/spf13/cobra" + + "github.com/tamnd/kage/pack" + "github.com/tamnd/kage/zim" ) // Execute builds the root command and runs it through fang. main passes the // signal-aware context so Ctrl-C cancels the in-flight clone and flushes resume // state. It returns the process exit code. func Execute(ctx context.Context) int { + // A kage binary with a ZIM appended runs as an offline viewer for that site, + // ignoring its arguments. A normal build has no trailer and falls through. + if ra, size, ok := pack.Embedded(); ok { + return runEmbeddedViewer(ctx, ra, size) + } + root := newRoot() opts := []fang.Option{ fang.WithVersion(Version), @@ -41,5 +54,38 @@ func newRoot() *cobra.Command { } root.AddCommand(newCloneCmd()) root.AddCommand(newServeCmd()) + root.AddCommand(newPackCmd()) + root.AddCommand(newOpenCmd()) return root } + +// runEmbeddedViewer serves the ZIM appended to this executable on an ephemeral +// local port and opens the browser. It runs until the context is cancelled +// (Ctrl-C) and ignores all command-line arguments: a packed binary is the site, +// not the kage CLI. +func runEmbeddedViewer(ctx context.Context, ra io.ReaderAt, size int64) int { + r, err := zim.NewReader(ra, size) + if err != nil { + fmt.Fprintln(os.Stderr, "kage: corrupt embedded archive:", err) + return 1 + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fmt.Fprintln(os.Stderr, "kage: cannot start viewer:", err) + return 1 + } + url := "http://" + ln.Addr().String() + fmt.Fprintln(os.Stderr, "serving offline site at "+url+" (Ctrl-C to stop)") + _ = pack.OpenInBrowser(url) + + srv := &http.Server{Handler: pack.Handler(r)} + go func() { + <-ctx.Done() + _ = srv.Close() + }() + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + fmt.Fprintln(os.Stderr, "kage:", err) + return 1 + } + return 0 +}