package gistatic import ( "flag" "fmt" "html/template" "io" "os" "os/exec" "path" "slices" "strings" g "gobang" gt "gotext" ) const ( dateFmt = "--date=format:%Y-%m-%dT%H:%M" ) var ( fn_wd = os.Getwd fn_mkdirp = func(path string) error { return os.MkdirAll(path, 0777) } fn_writeFile = func(path string, content string) error { return os.WriteFile(path, []byte(content), 0644) } logoSVGUncolored = g.Heredoc(` `) styleCSS = g.Heredoc(` :root { --color: black; --background-color: white; --background-contrast-color: hsl(0, 0%, 98%); --hover-color: hsl(0, 0%, 93%); --nav-color: hsl(0, 0%, 87%); --selected-color: hsl(0, 0%, 80%); --diff-added-color: hsl(120, 100%, 23%); --diff-removed-color: hsl(0, 100%, 47%); } @media(prefers-color-scheme: dark) { :root { --color: white; --background-color: black; --background-contrast-color: hsl(0, 0%, 2%); --hover-color: hsl(0, 0%, 7%); --nav-color: hsl(0, 0%, 13%); --selected-color: hsl(0, 0%, 20%); } body { color: var(--color); background-color: var(--background-color); } a { color: hsl(211, 100%, 60%); } a:visited { color: hsl(242, 100%, 80%); } } body { font-family: monospace; max-width: 1100px; margin: 0 auto 0 auto; } .logo { height: 6em; width: 6em; } .header-horizontal-grouping { display: flex; align-items: center; margin-top: 1em; margin-bottom: 1em; } .header-description { margin-left: 2em; } nav { margin-top: 2em; } nav ul { display: flex; list-style-type: none; margin-bottom: 0; } nav li { margin-left: 10px; } nav a, nav a:visited { padding: 2px 8px 0px 8px; color: var(--color); } .selected-nav-item { background-color: var(--nav-color); } hr { margin-top: 0; border: 0; border-top: 3px solid var(--nav-color); } table { margin: 2em auto; } th { padding-bottom: 1em; } tbody tr:hover { background-color: var(--hover-color); } td { padding-left: 1em; padding-right: 1em; } /* commit page */ .diff-added, .diff-removed { text-decoration: none; } .diff-added:target, .diff-removed:target { background-color: var(--selected-color); } .diff-added, .diff-added:visited { color: var(--diff-added-color); } .diff-removed, .diff-removed:visited { color: var(--diff-removed-color); } /* log page */ .log-commit-box { padding: 1em; margin: 1em; background-color: var(--background-contrast-color); } .log-commit-tag { padding: 2px; border: 1px solid; color: var(--color); } .log-head-highlight { background-color: #ff8888; /* FIXME: hsl + dark-mode */ } .log-branch-highlight { background-color: #88ff88; /* FIXME: hsl + dark-mode */ } .log-tag-highlight { background-color: #ffff88; /* FIXME: hsl + dark-mode */ } .pre-wrapping { overflow: auto; margin: 1em; } .log-notes { /* FIXME: yellow box goes until the end of the screen */ padding: 1em; background-color: #ffd; /* FIXME: hsl + dark-mode */ } .log-pagination { text-align: center; margin: 2em; } footer { text-align: center; } `) listingHTML = g.Must(template.New("").Parse(g.Heredoc(` {{.title}}
{{.logoAlt}}

{{.title}}


{{range .repositories}} {{end}}
{{.name}} {{.description}} {{.lastCommit}}
{{.name}} {{.description}}
`))) repoindexHTML = g.Must(template.New("").Parse(g.Heredoc(` {{.title}}
{{.logoAlt}}

{{.title}}


{{range .Repositories}} {{end}}
{{.name}} {{.description}} {{.lastCommit}}
{{.name}} {{.description}}
`))) branchesHTML = g.Must(template.New("").Parse(g.Heredoc(` {{.branches}} ยท {{.metadata.name}}
{{.logoAlt}}

{{.metadata.name}}

{{.metadata.description}}

{{if .cloneURL}} git clone {{.cloneURL}}/{{.metadata.name}}/ {{end}}

{{range .data.branches}} {{end}}
{{.branch}} {{.commitMessage}} {{.author}} {{.date}}
{{.name}} {{.messageSummary}} {{.author}}
`))) tagsHTML = g.Must(template.New("").Parse(g.Heredoc(` {{.title}}
{{.logoAlt}}

{{.title}}


{{range .Repositories}} {{end}}
{{.name}} {{.description}} {{.lastCommit}}
{{.name}} {{.description}}
`))) logHTML = g.Must(template.New("").Parse(g.Heredoc(` {{.title}}
{{.logoAlt}}

{{.title}}


{{range .Repositories}} {{end}}
{{.name}} {{.description}} {{.lastCommit}}
{{.name}} {{.description}}
`))) commitHTML = g.Must(template.New("").Parse(g.Heredoc(` {{.title}}
{{.logoAlt}}

{{.title}}


{{range .Repositories}} {{end}}
{{.name}} {{.description}} {{.lastCommit}}
{{.name}} {{.description}}
`))) sourceHTML = g.Must(template.New("").Parse(g.Heredoc(` {{.title}}
{{.logoAlt}}

{{.title}}


{{range .repositories}} {{end}}
{{.name}} {{.description}} {{.lastCommit}}
{{.name}} {{.description}}
`))) ) type repositoryTemplateT struct{ template *template.Template path string keys map[string]interface{} } type argsT struct{ allArgs []string subArgs []string cloneURL string outdir string } func logoSVG(color string) string { return strings.ReplaceAll(logoSVGUncolored, "$color", color) } func generateAssets(outdir string) error { err := fn_mkdirp(outdir + "/img/logo") if err != nil { return err } err = fn_writeFile(outdir + "/img/favicon.svg", logoSVG("black")) if err != nil { return err } err = fn_writeFile(outdir + "/img/logo/light.svg", logoSVG("black")) if err != nil { return err } err = fn_writeFile(outdir + "/img/logo/dark.svg", logoSVG("white")) if err != nil { return err } err = fn_writeFile(outdir + "/style.css", styleCSS) if err != nil { return err } return nil } func writePage( dir string, t *template.Template, filename string, keys map[string]interface{}, ) { fullpath := dir + "/" + filename s := strings.Builder{} g.PanicIf(fn_mkdirp(path.Dir(fullpath))) g.PanicIf(t.Execute(&s, keys)) g.PanicIf(fn_writeFile(fullpath, s.String())) } func cmd(stderr io.Writer, name string, args ...string) (string, error) { command := exec.Command(name, args...) command.Stderr = stderr outBytes, err := command.Output() if err != nil { return "", err } return strings.TrimSpace(string(outBytes)), nil } func branchesData( gitcmd func(...string) (string, error), ) ([]map[string]interface{}, error) { branches := []map[string]interface{}{} names, err := gitcmd("branch", "--format", "%(refname:lstrip=2)") if err != nil { return nil, err } for _, name := range strings.Split(names, "\n") { sha, err := gitcmd("rev-parse", name) if err != nil { return nil, err } messageSummary, err := gitcmd("log", "-1", "--format=%s", sha) if err != nil { return nil, err } author, err := gitcmd("log", "-1", "--format=%an", sha) if err != nil { return nil, err } date, err := gitcmd( "log", "-1", "--format=%cd", dateFmt, sha, ) if err != nil { return nil, err } branches = append(branches, map[string]interface{}{ "name": name, "sha": sha, "messageSummary": messageSummary, "author": author, "date": date, }) } return branches, nil } func generateRepository( args argsT, path string, stderr io.Writer, metadata map[string]interface{}, ) error { gitcmd := func(args ...string) (string, error) { return cmd( stderr, "git", slices.Concat( []string{"-C", path}, args, )..., ) } branches, err := branchesData(gitcmd) if err != nil { return err } dir := args.outdir + "/" + (metadata["name"].(string)) keys := map[string]interface{}{ "metadata": metadata, "cloneURL": args.cloneURL, "logoAlt": gt.Gettext( "Outlined icon of 3 melting ice cubes", ), "language": gt.Gettext("en"), "branches": gt.Gettext("branches"), "tags": gt.Gettext("tags"), "log": gt.Gettext("log"), "commit": gt.Gettext("commit"), "branch": gt.Gettext("Branch"), "tag": gt.Gettext("Tag"), "commitMessage": gt.Gettext("Commit message"), "author": gt.Gettext("Author"), "date": gt.Gettext("Date"), "data": map[string]interface{}{ "branches": branches, }, } writePage(dir, repoindexHTML, "index.html", keys) writePage(dir, branchesHTML, "branches.html", keys) // commits := allCommits() return nil } func basicRepositoryMetadata(path string) (map[string]interface{}, error) { gitdirBytes, err := exec.Command( "git", "-C", path, "rev-parse", "--git-dir", ).Output() if err != nil { return nil, err } gitdir := strings.TrimSpace(string(gitdirBytes)) descriptionPath := path + "/" + gitdir + "/" + "description" descriptionBytes, err := os.ReadFile(descriptionPath) if err != nil && !os.IsNotExist(err) { return nil, err } description := strings.TrimSpace(string(descriptionBytes)) lastCommitBytes, err := exec.Command( "git", "-C", path, "log", "-1", "--format=%cd", dateFmt, ).Output() if err != nil { return nil, err } lastCommit := strings.TrimSpace(string(lastCommitBytes)) return map[string]interface{}{ "name": path, "description": description, "lastCommitDate": lastCommit, }, nil } func generateRepositories( args argsT, stderr io.Writer, ) ([]map[string]interface{}, error) { allMetadata := []map[string]interface{}{} for _, path := range args.subArgs { metadata, err := basicRepositoryMetadata(path) if err != nil { return nil, err } err = generateRepository(args, path, stderr, metadata) if err != nil { return nil, err } allMetadata = append(allMetadata, metadata) } return allMetadata, nil } func generateListing(args argsT, allMetadata []map[string]interface{}) error { keys := map[string]interface{}{ "logoAlt": gt.Gettext( "Outlined icon of 3 melting ice cubes", ), "language": gt.Gettext("en"), "pageDescription": gt.Gettext("Listing of repositories"), "title": gt.Gettext("Repositories"), "name": gt.Gettext("Name"), "description": gt.Gettext("Description"), "lastCommit": gt.Gettext("Last commit"), "generatedWith": gt.Gettext("Generated with"), "repositories": allMetadata, } s := strings.Builder{} err := listingHTML.Execute(&s, keys) if err != nil { return err } err = fn_writeFile(args.outdir + "/index.html", s.String()) if err != nil { return err } return nil } func run(args argsT, _ io.Reader, _ io.Writer, stderr io.Writer) int { err := fn_mkdirp(args.outdir) if err != nil { fmt.Fprintln(stderr, err) return 1 } err = generateAssets(args.outdir) if err != nil { fmt.Fprintln(stderr, err) return 1 } allMetadata, err := generateRepositories(args, stderr) if err != nil { fmt.Fprintln(stderr, err) return 1 } err = generateListing(args, allMetadata) if err != nil { fmt.Fprintln(stderr, err) return 1 } return 0 } func usage(argv0 string, w io.Writer) { fmt.Fprintf( w, "Usage: %s [-o DIRECTORY] [-u CLONE_URL] REPOSITORY...\n", argv0, ) } func getopt(allArgs []string, w io.Writer) (argsT, int) { argv0 := allArgs[0] argv := allArgs[1:] fs := flag.NewFlagSet("", flag.ContinueOnError) fs.Usage = func() {} fs.SetOutput(w) outdir := fs.String( "o", g.Must(fn_wd()), "Where to store the generated files", ) cloneURL := fs.String( "u", "", "The prefix of the online cloning addresss", ) if fs.Parse(argv) != nil { usage(argv0, w) return argsT{}, 2 } subArgs := fs.Args() return argsT{ allArgs: allArgs, subArgs: subArgs, cloneURL: *cloneURL, outdir: *outdir, }, 0 } func Main() { g.Init() gt.Init(Name, LOCALEDIR) args, rc := getopt(os.Args, os.Stderr) g.ExitIf(rc) os.Exit(run(args, os.Stdin, os.Stdout, os.Stderr)) }