// Copyright 2020 Staysail Systems, Inc. // // This software is supplied under the terms of the MIT License, a // copy of which should be located in the distribution where this // file was obtained (LICENSE.txt). A copy of the license may also be // found online at https://opensource.org/licenses/MIT. // package main import ( "context" "flag" "fmt" "io/ioutil" "log" "os" "path" "sort" "strings" "time" "github.com/bytesparadise/libasciidoc" "github.com/bytesparadise/libasciidoc/pkg/configuration" "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/storage/memory" "github.com/google/uuid" jww "github.com/spf13/jwalterweatherman" ) type Configuration struct { Version string Debug bool Trace bool Quiet bool DryRun bool Author string Email string Url string Message string } var Config Configuration func init() { flag.StringVar(&Config.Version, "v", "tip", "Version to publish") flag.BoolVar(&Config.Debug, "d", false, "Enable debugging") flag.BoolVar(&Config.Trace, "t", false, "Enable tracing") flag.BoolVar(&Config.Quiet, "q", false, "Run quietly") flag.BoolVar(&Config.DryRun, "n", false, "Dry run, does not push changes") flag.StringVar(&Config.Url, "u", "ssh://git@github.com/nanomsg/nng.git", "URL of repo to publish from") flag.StringVar(&Config.Email, "E", "info@staysail.tech", "Author email for commit") flag.StringVar(&Config.Author, "A", "Staysail Systems, Inc.", "Author name for commit") flag.StringVar(&Config.Message, "m", "", "Commit message") } func (g *Global) CheckError(err error, prefix string, args ...interface{}) { if err == nil { g.Log.TRACE.Printf("%s: ok", fmt.Sprintf(prefix, args...)) return } g.Log.FATAL.Fatalf("Error: %s: %v", fmt.Sprintf(prefix, args...), err) } func (g *Global) Fatal(format string, args ...interface{}) { g.Log.FATAL.Fatalf("Error: %s", fmt.Sprintf(format, args...)) } type Section struct { Name string Synopsis string Description string Pages []*Page } type Page struct { Name string Section string Description string Content string } type Global struct { Config Configuration SrcFs billy.Filesystem DstFs billy.Filesystem DstDir string LaConfig configuration.Configuration Sections map[string]*Section Pages map[string]*Page Repo *git.Repository Index string ToC string Added map[string]bool WorkTree *git.Worktree Branch string OldHash plumbing.Hash NewHash plumbing.Hash Log *jww.Notepad } func (g *Global) Init() { g.Config = Config g.Sections = make(map[string]*Section) g.Pages = make(map[string]*Page) g.Added = make(map[string]bool) g.SrcFs = memfs.New() g.DstFs = memfs.New() g.DstDir = path.Join("man", g.Config.Version) g.LaConfig = configuration.Configuration{ AttributeOverrides: map[string]string{ "nofooter": "yes", "icons": "font", "linkcss": "yes", "source-highlighter": "pygments", }, } thresh := jww.LevelInfo if g.Config.Quiet { thresh = jww.LevelError } if g.Config.Debug { thresh = jww.LevelDebug } if g.Config.Trace { thresh = jww.LevelTrace } g.Log = jww.NewNotepad(thresh, thresh, os.Stdout, ioutil.Discard, "", log.Ldate|log.Ltime) } func (g *Global) Destroy() { } func (g *Global) Debug(format string, args ...interface{}) { g.Log.DEBUG.Printf(format, args...) } func (g *Global) Print(format string, args ...interface{}) { g.Log.INFO.Printf(format, args...) } func (g *Global) CloneSource() { tag := g.Config.Version if tag == "" || tag == "tip" { tag = "master" } ref := plumbing.NewBranchReferenceName(tag) if strings.HasPrefix(tag, "v") { ref = plumbing.NewTagReferenceName(tag) } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() now := time.Now() _, err := git.CloneContext(ctx, memory.NewStorage(), g.SrcFs, &git.CloneOptions{ URL: g.Config.Url, ReferenceName: ref, }) g.CheckError(err, "clone source") g.Debug("Cloned source (%s) in %v", tag, time.Since(now)) } func (g *Global) ClonePages() { ref := plumbing.NewBranchReferenceName("gh-pages") ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() now := time.Now() repo, err := git.CloneContext(ctx, memory.NewStorage(), g.DstFs, &git.CloneOptions{ URL: g.Config.Url, ReferenceName: ref, RemoteName: "origin", }) g.CheckError(err, "clone gh-pages") g.Repo = repo g.Debug("Cloned pages in %v", time.Since(now)) } func (g *Global) ProcessManPage(page os.FileInfo) { source := g.ReadFile(page.Name()) // Let's look for the description inName := false desc := "" name := "" for _, line := range strings.Split(source, "\n") { line = strings.TrimRight(line, " \t\r") if line == "" { continue } if line == "== NAME" { inName = true continue } if inName { w := strings.SplitN(line, " - ", 2) if len(w) != 2 || w[1] == "" { g.Fatal("page %s NAME malformed", page.Name()) } name = w[0] desc = w[1] strings.TrimSpace(name) strings.TrimSpace(desc) break } } if desc == "" { g.Fatal("page %s NAME missing", page.Name()) } html := &strings.Builder{} // Emit the title, as we are not letting libasciidoc do it (stripping headers) cfg := g.LaConfig cfg.Filename = page.Name() cfg.LastUpdated = page.ModTime() metadata, err := libasciidoc.Convert(strings.NewReader(source), html, cfg) g.CheckError(err, "processing page %s", page.Name()) w := strings.SplitN(metadata.Title, "(", 2) sect := strings.TrimSuffix(w[1], ")") if len(w) != 2 || name != w[0] || !strings.HasSuffix(w[1], ")") { g.Fatal("page %s title incorrect (%s)", page.Name(), name) } if page.Name() != name+"."+sect+".adoc" { g.Fatal("page %s(%s) does not match file name %s", name, sect, page.Name()) } result := &strings.Builder{} _, _ = fmt.Fprintf(result, "---\n") _, _ = fmt.Fprintf(result, "version: %s\n", g.Config.Version) _, _ = fmt.Fprintf(result, "layout: %s\n", "manpage_v2") _, _ = fmt.Fprintf(result, "---\n") _, _ = fmt.Fprintf(result, "

%s(%s)

\n", name, sect) result.WriteString(html.String()) g.Pages[page.Name()] = &Page{ Name: name, Section: sect, Description: desc, Content: result.String(), } g.Log.TRACE.Printf("HTML for %s:\n%s\n", name, result.String()) } func (g *Global) ReadFile(name string) string { f, err := g.SrcFs.Open(path.Join("docs/man", name)) g.CheckError(err, "open file %s", name) b, err := ioutil.ReadAll(f) g.CheckError(err, "read file %s", name) return string(b) } func (g *Global) LoadSection(name string) { section := strings.TrimPrefix(name, "man") g.Sections[section] = &Section{ Name: section, Synopsis: g.ReadFile(name + ".sect"), Description: g.ReadFile(name + ".desc"), } } func (g *Global) ProcessSource() { pages, err := g.SrcFs.ReadDir("docs/man") g.CheckError(err, "reading source directory") count := 0 g.Debug("Total of %d files in man directory", len(pages)) now := time.Now() for _, page := range pages { if page.IsDir() { continue } if strings.HasSuffix(page.Name(), ".sect") { g.LoadSection(strings.TrimSuffix(page.Name(), ".sect")) } if !strings.HasSuffix(page.Name(), ".adoc") { continue } g.ProcessManPage(page) count++ } g.Debug("Processed %d pages in %v", count, time.Since(now)) } func (g *Global) GenerateToC() { toc := &strings.Builder{} idx := &strings.Builder{} for _, page := range g.Pages { if sect := g.Sections[page.Section]; sect == nil { g.Fatal("page %s section %s not found", page.Name, page.Section) } else { sect.Pages = append(sect.Pages, page) } } var sects []string for name, sect := range g.Sections { sects = append(sects, name) sort.Slice(sect.Pages, func(i, j int) bool { return sect.Pages[i].Name < sect.Pages[j].Name }) } sort.Strings(sects) // And also the index page. // Emit the toc leader part toc.WriteString("\n") index := &strings.Builder{} _, _ = fmt.Fprintf(index, "---\n") _, _ = fmt.Fprintf(index, "version: %s\n", g.Config.Version) _, _ = fmt.Fprintf(index, "layout: %s\n", "manpage_v2") _, _ = fmt.Fprintf(index, "---\n") _, _ = fmt.Fprintf(index, "

NNG Reference Manual

\n") cfg := g.LaConfig cfg.Filename = "index.adoc" _, err := libasciidoc.Convert(strings.NewReader(idx.String()), index, cfg) g.CheckError(err, "formatting index") g.Index = index.String() g.ToC = toc.String() } func (g *Global) CreateBranch() { brName := uuid.New().String() var err error refName := plumbing.ReferenceName("refs/heads/" + brName) g.Branch = brName g.WorkTree, err = g.Repo.Worktree() g.CheckError(err, "getting worktree") err = g.WorkTree.Checkout(&git.CheckoutOptions{ Branch: refName, Create: true, }) g.CheckError(err, "creating branch") g.Print("Checked out branch %v", brName) pr, err := g.Repo.Head() g.CheckError(err, "getting head hash") g.OldHash = pr.Hash() } func (g *Global) WriteFile(name string, content string) { full := path.Join(g.DstDir, name) f, err := g.DstFs.Create(full) g.CheckError(err, "creating file %s", name) _, err = f.Write([]byte(content)) g.CheckError(err, "writing file %s", name) err = f.Close() g.CheckError(err, "closing file %s", name) g.Add(name) } func (g *Global) Add(name string) { g.Log.TRACE.Printf("Adding file %s", name) g.Added[name] = true } func (g *Global) Delete(name string) { g.Debug("Removing file %s", name) _, err := g.WorkTree.Remove(path.Join(g.DstDir, name)) g.CheckError(err, "removing file %s", name) } func (g *Global) Commit() { if status, err := g.WorkTree.Status(); status == nil { g.CheckError(err, "obtaining status") } else if status.IsClean() { g.Print("No changes to commit.") return } message := g.Config.Message if message == "" { message = "Manual page updates for " + g.Config.Version } var err error g.NewHash, err = g.WorkTree.Commit(message, &git.CommitOptions{ Author: &object.Signature{ Email: g.Config.Email, Name: g.Config.Author, When: time.Now(), }, }) g.CheckError(err, "committing branch") } func (g *Global) Push() { if g.NewHash.IsZero() { g.Print("Nothing to push.") return } ci, err := g.Repo.Log(&git.LogOptions{ From: g.NewHash, }) g.CheckError(err, "getting commit log") commit, err := ci.Next() g.CheckError(err,"getting single commit") if commit != nil { g.Print(commit.String()) if fs, _ := commit.Stats(); fs != nil { g.Debug(fs.String()) } } if g.Config.DryRun { g.Print("Not pushing changes (dry-run mode.)") } else { err := g.Repo.Push(&git.PushOptions{ RemoteName: "origin", }) g.CheckError(err, "pushing changes") g.Print("Pushed branch %v\n", g.Branch) } } func (g *Global) WriteOutput() { for _, p := range g.Pages { fName := fmt.Sprintf("%s.%s.html", p.Name, p.Section) g.WriteFile(fName, p.Content) } g.WriteFile("_toc.html", g.ToC) g.WriteFile("index.html", g.Index) _, err := g.WorkTree.Add(g.DstDir) g.CheckError(err, "adding directory") files, err := g.DstFs.ReadDir(g.DstDir) g.CheckError(err, "scanning destination directory") for _, file := range files { if file.IsDir() { continue } if g.Added[file.Name()] { continue } g.Delete(file.Name()) } status, err := g.WorkTree.Status() g.CheckError(err, "obtaining commit status") if !status.IsClean() { g.Debug("No changes.") } else { g.Debug(status.String()) } } func main() { g := &Global{} flag.Parse() g.Init() defer g.Destroy() g.CloneSource() g.ClonePages() g.ProcessSource() g.GenerateToC() g.CreateBranch() g.WriteOutput() g.Commit() g.Push() }