Wire kage pack and kage open into the CLI
pack packs a mirror to a zim file or a runnable viewer, accepting a bare host that it resolves against the default output directory. open serves a zim over http like serve does for a folder. Execute checks for an appended archive first, so a packed kage runs as an offline viewer on an ephemeral port and ignores its arguments.
This commit is contained in:
+63
@@ -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 <file.zim>",
|
||||
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
|
||||
}
|
||||
+168
@@ -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 <mirror-dir>",
|
||||
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 <title>)")
|
||||
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])
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user