Files
kopia/tools/cli2md/cli2md.go
Julio Lopez 9c1aec59e4 docs(site): fix CLI command docs (#4979)
Also, minor updates for contrib docs
2025-11-12 17:19:34 -08:00

348 lines
8.0 KiB
Go

// Command cli2md generates documentation pages from CLI flags.
package main
import (
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/alecthomas/kingpin/v2"
"github.com/pkg/errors"
"github.com/kopia/kopia/cli"
_ "github.com/kopia/kopia/internal/logfile"
)
//nolint:gochecknoglobals
var baseDir = flag.String("base-dir", "content/docs/Reference/Command-Line", "Base directory")
const (
advancedSection = "Advanced"
advancedCommandsWeight = 6
commonSection = "Common"
commonCommandsWeight = 5
dirMode = 0o750
)
// The group at the start is to avoid matching things like "in-place".
var escapeFlagsRe = regexp.MustCompile(`(^|\s)(--?\w+[-\w]*)`)
// escapeFlags escapes command-line flag references in help text by wrapping them in backticks.
func escapeFlags(text string) string {
return escapeFlagsRe.ReplaceAllString(text, "$1`$2`")
}
//nolint:gochecknoglobals
var overrideDefault = map[string]string{
"config-file": "repository.config",
"log-dir": "kopia",
}
func emitFlags(w io.Writer, flags []*kingpin.FlagModel) {
if len(flags) == 0 {
return
}
fmt.Fprintf(w, "| Flag | Short | Default | Help |\n") //nolint:errcheck
fmt.Fprintf(w, "| ---- | ----- | --- | --- |\n") //nolint:errcheck
for _, f := range sortFlags(flags) {
maybeAdvanced := ""
if f.Hidden {
maybeAdvanced = "[ADV] "
}
shortFlag := ""
if f.Short != 0 {
shortFlag = "`-" + string([]byte{byte(f.Short)}) + "`"
}
defaultValue := ""
if len(f.Default) > 0 {
defaultValue = f.Default[0]
}
if def, ok := overrideDefault[f.Name]; ok {
defaultValue = def
}
if defaultValue != "" {
defaultValue = "`" + defaultValue + "`"
}
if f.IsBoolFlag() {
if defaultValue == "" {
defaultValue = "`false`"
}
fmt.Fprintf(w, "| `--[no-]%v` | %v | %v | %v%v |\n", f.Name, shortFlag, defaultValue, maybeAdvanced, escapeFlags(f.Help)) //nolint:errcheck
} else {
fmt.Fprintf(w, "| `--%v` | %v | %v | %v%v |\n", f.Name, shortFlag, defaultValue, maybeAdvanced, escapeFlags(f.Help)) //nolint:errcheck
}
}
fmt.Fprintf(w, "\n") //nolint:errcheck
}
func combineFlags(lists ...[]*kingpin.FlagModel) []*kingpin.FlagModel {
var result []*kingpin.FlagModel
for _, list := range lists {
result = append(result, list...)
}
return result
}
func sortFlags(f []*kingpin.FlagModel) []*kingpin.FlagModel {
sort.Slice(f, func(i, j int) bool {
a, b := f[i], f[j]
if l, r := a.Hidden, b.Hidden; l != r {
return !l
}
return a.Name < b.Name
})
return f
}
func emitArgs(w io.Writer, args []*kingpin.ArgModel) {
if len(args) == 0 {
return
}
fmt.Fprintf(w, "| Argument | Help |\n") //nolint:errcheck
fmt.Fprintf(w, "| -------- | --- |\n") //nolint:errcheck
args2 := append([]*kingpin.ArgModel(nil), args...)
sort.Slice(args2, func(i, j int) bool {
return args2[i].Name < args2[j].Name
})
for _, f := range args2 {
fmt.Fprintf(w, "| `%v` | %v |\n", f.Name, escapeFlags(f.Help)) //nolint:errcheck
}
fmt.Fprintf(w, "\n") //nolint:errcheck
}
func generateAppFlags(app *kingpin.ApplicationModel) error {
f, err := os.Create(filepath.Join(*baseDir, "flags.md"))
if err != nil {
return errors.Wrap(err, "unable to create common flags file")
}
defer f.Close() //nolint:errcheck
title := "Flags"
//nolint:errcheck
fmt.Fprintf(f, `---
title: %q
linkTitle: %q
weight: 3
---
`, title, title)
emitFlags(f, app.Flags)
return nil
}
func generateCommands(app *kingpin.ApplicationModel, section string, weight int, advanced bool) error {
dir := filepath.Join(*baseDir, section)
if err := os.MkdirAll(dir, dirMode); err != nil {
return errors.Wrapf(err, "error creating section directory for %v", section)
}
f, err := os.Create(filepath.Join(dir, "_index.md")) //nolint:gosec
if err != nil {
return errors.Wrap(err, "unable to create common flags file")
}
defer f.Close() //nolint:errcheck
title := section + " Commands"
//nolint:errcheck
fmt.Fprintf(f, `---
title: %q
linkTitle: %q
weight: %v
hide_summary: true
no_list: true
---
`, title, title, weight)
flat := flattenCommands(app.Commands)
for _, c := range flat {
generateSubcommands(f, dir, c.Help, c.Commands, advanced)
}
return nil
}
func flattenCommands(cmds []*kingpin.CmdModel) []*kingpin.CmdModel {
var result []*kingpin.CmdModel
commonRoot := &kingpin.CmdModel{
Name: "Common Commands",
Help: "Common Commands",
CmdGroupModel: &kingpin.CmdGroupModel{},
}
result = append(result, commonRoot)
for _, c := range cmds {
if len(c.Commands) == 0 {
commonRoot.Commands = append(commonRoot.Commands, c)
continue
}
root := &kingpin.CmdModel{
Name: c.Name,
FullCommand: c.FullCommand,
Help: c.Help,
Hidden: c.Hidden,
CmdGroupModel: &kingpin.CmdGroupModel{},
}
result = append(result, root)
root.Commands = flattenChildren(c, nil, c.Hidden)
}
return result
}
func flattenChildren(cmd *kingpin.CmdModel, parentFlags []*kingpin.FlagModel, forceHidden bool) []*kingpin.CmdModel {
var result []*kingpin.CmdModel
cmdFlags := combineFlags(parentFlags, cmd.Flags)
if len(cmd.Commands) == 0 {
cmdClone := *cmd
if forceHidden {
cmdClone.Hidden = true
}
cmdClone.Flags = cmdFlags
result = append(result, &cmdClone)
} else {
for _, c := range cmd.Commands {
result = append(result, flattenChildren(c, cmdFlags, c.Hidden || forceHidden)...)
}
}
return result
}
func generateSubcommands(w io.Writer, dir, sectionTitle string, cmds []*kingpin.CmdModel, advanced bool) {
cmds = append([]*kingpin.CmdModel(nil), cmds...)
first := true
for _, c := range cmds {
if c.Hidden != advanced {
continue
}
if first {
fmt.Fprintf(w, "\n### %v\n\n", strings.TrimSuffix(sectionTitle, ".")) //nolint:errcheck
first = false
}
subcommandSlug := strings.ReplaceAll(c.FullCommand, " ", "-")
helpSummary := strings.SplitN(c.Help, "\n", 2)[0] //nolint:mnd
helpSummary = strings.TrimSuffix(helpSummary, ".")
fmt.Fprintf(w, "* [`%v`](%v) - %v\n", c.FullCommand, subcommandSlug+"/", helpSummary) //nolint:errcheck
generateSubcommandPage(filepath.Join(dir, subcommandSlug+".md"), c)
}
}
func generateSubcommandPage(fname string, cmd *kingpin.CmdModel) {
f, err := os.Create(fname) //nolint:gosec
if err != nil {
log.Fatalf("unable to create page: %v", err)
}
defer f.Close() //nolint:errcheck
title := cmd.FullCommand
//nolint:errcheck
fmt.Fprintf(f, `---
title: %q
linkTitle: %q
weight: 10
toc_hide: true
hide_summary: true
---
`, title, title)
var (
argSummary strings.Builder
flagSummary strings.Builder
)
for _, a := range cmd.Args {
if a.Required {
argSummary.WriteString(" <")
argSummary.WriteString(a.Name)
argSummary.WriteRune('>')
} else {
argSummary.WriteString(" [")
argSummary.WriteString(a.Name)
argSummary.WriteRune(']')
}
}
for _, fl := range cmd.Flags {
if fl.Required {
flagSummary.WriteString(" \\\n --" + fl.Name + "=...")
}
}
fmt.Fprintf(f, "```shell\n$ kopia %v%v%v\n```\n\n", cmd.FullCommand, flagSummary.String(), argSummary.String()) //nolint:errcheck
fmt.Fprintf(f, "%v\n\n", escapeFlags(cmd.Help)) //nolint:errcheck
emitFlags(f, cmd.Flags)
emitArgs(f, cmd.Args)
}
func main() {
flag.Parse()
if _, err := os.Stat(*baseDir); err != nil {
log.Fatalf("invalid base directory: %v", err)
}
_ = os.RemoveAll(filepath.Join(*baseDir, commonSection))
_ = os.RemoveAll(filepath.Join(*baseDir, advancedSection))
kingpinApp := kingpin.New("kopia", "Kopia - Fast And Secure Open-Source Backup").Author("http://kopia.github.io/")
cli.NewApp().Attach(kingpinApp)
app := kingpinApp.Model()
if err := generateAppFlags(app); err != nil {
log.Fatalf("unable to generate common flags: %v", err)
}
if err := generateCommands(app, commonSection, commonCommandsWeight, false); err != nil {
log.Fatalf("unable to generate common commands: %v", err)
}
if err := generateCommands(app, advancedSection, advancedCommandsWeight, true); err != nil {
log.Fatalf("unable to generate advanced commands: %v", err)
}
}