From 32e34c577ecde3ea6c507fc0390fe1bd9cfc89ef Mon Sep 17 00:00:00 2001 From: Oleg Isakov Date: Wed, 28 Jan 2026 12:00:18 +0200 Subject: [PATCH 1/3] add gendoc cmd --- .gitignore | 1 + Makefile | 3 + cmd/gendoc/main.go | 35 +++++ go.mod | 2 + go.sum | 3 + internal/docgen/generator.go | 280 +++++++++++++++++++++++++++++++++++ 6 files changed, 324 insertions(+) create mode 100644 cmd/gendoc/main.go create mode 100644 internal/docgen/generator.go diff --git a/.gitignore b/.gitignore index 174db53..60e6b63 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist .idea/ cp.out .DS_Store +docs/generated diff --git a/Makefile b/Makefile index 67ac571..5e38c0b 100644 --- a/Makefile +++ b/Makefile @@ -42,3 +42,6 @@ generate: deps ./internal/mocks/cloud_computing_regions_service.go \ ./internal/mocks/cloud_block_storage_volumes_service.go \ ./internal/mocks/cloud_block_storage_backups_service.go + +docs: + go run cmd/gendoc/main.go diff --git a/cmd/gendoc/main.go b/cmd/gendoc/main.go new file mode 100644 index 0000000..c1016f9 --- /dev/null +++ b/cmd/gendoc/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/serverscom/srvctl/cmd" + "github.com/serverscom/srvctl/internal/docgen" +) + +func main() { + rootCmd := cmd.NewRootCmd("dev") + + baseDocsPath := "docs" + markdownOutputPath := "docs/generated/markdown" + manOutputPath := "docs/generated/man" + + generator := docgen.NewGenerator( + baseDocsPath, + markdownOutputPath, + manOutputPath, + ) + + fmt.Println("Generating documentation...") + if err := generator.Generate(rootCmd); err != nil { + log.Fatalf("Failed to generate documentation: %v", err) + } + + fmt.Println("\nDocumentation generated successfully!") + fmt.Println(" Markdown: ", markdownOutputPath) + fmt.Println(" Man pages: ", manOutputPath) + + os.Exit(0) +} diff --git a/go.mod b/go.mod index 58c1033..8cd8d76 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/serverscom/srvctl go 1.23.0 require ( + github.com/cpuguy83/go-md2man/v2 v2.0.7 github.com/creack/pty v1.1.24 github.com/jmespath/go-jmespath v0.4.0 github.com/onsi/gomega v1.38.0 @@ -24,6 +25,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.10.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.14.0 // indirect diff --git a/go.sum b/go.sum index 64a8f63..6beb646 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -40,6 +42,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= diff --git a/internal/docgen/generator.go b/internal/docgen/generator.go new file mode 100644 index 0000000..bb01297 --- /dev/null +++ b/internal/docgen/generator.go @@ -0,0 +1,280 @@ +package docgen + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/cpuguy83/go-md2man/v2/md2man" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Generator generates documentation from cobra commands +type Generator struct { + docsPath string // base path to docs/ folder with description/examples + markdownPath string // output path for generated markdown files + manPath string // output path for generated man pages + template *template.Template // reusable template for doc generation +} + +// ExtraContent holds additional markdown docs for a command +type ExtraContent struct { + Description string + Examples string +} + +const docTemplate = `# NAME + +{{.Name}} - {{.Short}} + +# SYNOPSIS + +{{.UseLine}} + +# DESCRIPTION + +{{.Description}} + +# OPTIONS + +{{.Options}} + +{{if .SubCommands}} +# SUB COMMANDS + +{{.SubCommands}} +{{end}} + +{{if .Examples}} +# EXAMPLES + +{{.Examples}} +{{end}} +` + +// NewGenerator creates a new documentation generator +func NewGenerator(docsPath, markdownPath, manPath string) *Generator { + tmpl, err := template.New("doc").Parse(docTemplate) + if err != nil { + // This should never happen with a valid template + panic(fmt.Sprintf("failed to parse documentation template: %v", err)) + } + + return &Generator{ + docsPath: docsPath, + markdownPath: markdownPath, + manPath: manPath, + template: tmpl, + } +} + +// Generate generates documentation for all commands +func (g *Generator) Generate(rootCmd *cobra.Command) error { + if err := os.MkdirAll(g.markdownPath, 0755); err != nil { + return fmt.Errorf("failed to create markdown directory: %w", err) + } + if err := os.MkdirAll(g.manPath, 0755); err != nil { + return fmt.Errorf("failed to create man directory: %w", err) + } + + if err := g.processCommand(rootCmd, ""); err != nil { + return err + } + + return nil +} + +// processCommand processes a single command and its subcommands recursively +func (g *Generator) processCommand(cmd *cobra.Command, parentPath string) error { + // Skip if command is hidden or deprecated + if cmd.Hidden || cmd.Deprecated != "" { + return nil + } + + // Build command path (e.g., "hosts-ds-add" for "hosts ds add") + commandPath := g.getCommandPath(cmd, parentPath) + + // Read extra content from docs/srvctl-/ + extra, err := g.readExtraContent(commandPath) + if err != nil { + return fmt.Errorf("failed to read extra content for %s: %w", commandPath, err) + } + + // Generate markdown + markdown, err := g.generateMarkdown(cmd, extra, commandPath) + if err != nil { + return fmt.Errorf("failed to generate markdown for %s: %w", commandPath, err) + } + + // Write markdown file + mdFilename := fmt.Sprintf("%s.md", commandPath) + mdPath := filepath.Join(g.markdownPath, mdFilename) + if err := os.WriteFile(mdPath, []byte(markdown), 0644); err != nil { + return fmt.Errorf("failed to write markdown file %s: %w", mdPath, err) + } + + // Convert to man page + manFilename := fmt.Sprintf("%s.1", commandPath) + manPath := filepath.Join(g.manPath, manFilename) + if err := g.convertToMan(markdown, manPath); err != nil { + return fmt.Errorf("failed to convert to man page %s: %w", manPath, err) + } + + fmt.Printf("Generated: %s -> %s, %s\n", commandPath, mdPath, manPath) + + // Process subcommands recursively + for _, subCmd := range cmd.Commands() { + if err := g.processCommand(subCmd, commandPath); err != nil { + return err + } + } + + return nil +} + +// getCommandPath builds the command path (e.g., "srvctl-hosts-ds-add") +func (g *Generator) getCommandPath(cmd *cobra.Command, parentPath string) string { + cmdName := cmd.Name() + if parentPath == "" { + return cmdName + } + return parentPath + "-" + cmdName +} + +// readExtraContent reads description.md and examples.md if they exist +func (g *Generator) readExtraContent(commandPath string) (*ExtraContent, error) { + extra := &ExtraContent{} + + // Path to docs/srvctl-/ + docDir := filepath.Join(g.docsPath, commandPath) + + // Read description.md if exists + descPath := filepath.Join(docDir, "description.md") + if content, err := os.ReadFile(descPath); err == nil { + extra.Description = string(content) + } + + // Read examples.md if exists + examplesPath := filepath.Join(docDir, "examples.md") + if content, err := os.ReadFile(examplesPath); err == nil { + extra.Examples = string(content) + } + + return extra, nil +} + +// generateMarkdown generates markdown documentation for a command +func (g *Generator) generateMarkdown(cmd *cobra.Command, extra *ExtraContent, commandPath string) (string, error) { + // Build description section + description := strings.TrimSpace(cmd.Long) + if description == "" { + description = cmd.Short + } + if extra.Description != "" { + if description != "" { + description += "\n\n" + } + description += strings.TrimSpace(extra.Description) + } + + // Build options section + options := g.buildOptionsSection(cmd) + + // Build subcommands section + subCommands := g.buildSubCommandsSection(cmd) + + // Build examples section + examples := strings.TrimSpace(extra.Examples) + + // Prepare data for template + data := map[string]string{ + "Name": commandPath, + "Short": cmd.Short, + "UseLine": cmd.UseLine(), + "Description": description, + "Options": options, + "SubCommands": subCommands, + "Examples": examples, + } + + var buf bytes.Buffer + if err := g.template.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} + +// buildOptionsSection builds the OPTIONS section +func (g *Generator) buildOptionsSection(cmd *cobra.Command) string { + var buf bytes.Buffer + + // Local flags + if cmd.LocalFlags().HasFlags() { + buf.WriteString("## Local Flags\n\n") + g.formatFlags(&buf, cmd.LocalFlags()) + } + + // Inherited flags (global) + if cmd.InheritedFlags().HasFlags() { + buf.WriteString("## Global Flags\n\n") + g.formatFlags(&buf, cmd.InheritedFlags()) + } + + return strings.TrimSpace(buf.String()) +} + +// formatFlags formats flag set into buffer +func (g *Generator) formatFlags(buf *bytes.Buffer, flags *pflag.FlagSet) { + flags.VisitAll(func(flag *pflag.Flag) { + if flag.Hidden { + return + } + fmt.Fprintf(buf, "**--%s**", flag.Name) + if flag.Shorthand != "" { + fmt.Fprintf(buf, ", **-%s**", flag.Shorthand) + } + if flag.Value.Type() != "bool" { + fmt.Fprintf(buf, " *%s*", flag.Value.Type()) + } + buf.WriteString("\n\n") + if flag.Usage != "" { + fmt.Fprintf(buf, " %s\n\n", flag.Usage) + } + if flag.DefValue != "" && flag.DefValue != "false" && flag.DefValue != "[]" && flag.DefValue != "0" { + fmt.Fprintf(buf, " Default: `%s`\n\n", flag.DefValue) + } + }) +} + +// buildSubCommandsSection builds the SUB COMMANDS section +func (g *Generator) buildSubCommandsSection(cmd *cobra.Command) string { + var buf bytes.Buffer + + for _, subCmd := range cmd.Commands() { + if subCmd.Hidden || subCmd.Deprecated != "" { + continue + } + fmt.Fprintf(&buf, "**%s**\n\n", subCmd.Name()) + if subCmd.Short != "" { + fmt.Fprintf(&buf, " %s\n\n", subCmd.Short) + } + } + + return strings.TrimSpace(buf.String()) +} + +// convertToMan converts markdown to man page format +func (g *Generator) convertToMan(markdown string, outputPath string) error { + manContent := md2man.Render([]byte(markdown)) + + if err := os.WriteFile(outputPath, manContent, 0644); err != nil { + return fmt.Errorf("failed to write man page: %w", err) + } + + return nil +} From c4cb221463e2224717e37e12a73d1458b81b1913 Mon Sep 17 00:00:00 2001 From: Oleg Isakov Date: Wed, 28 Jan 2026 12:04:56 +0200 Subject: [PATCH 2/3] phony docs make command --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5e38c0b..11ab1d6 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +.PHONY: docs + deps: go mod tidy go mod vendor @@ -44,4 +46,4 @@ generate: deps ./internal/mocks/cloud_block_storage_backups_service.go docs: - go run cmd/gendoc/main.go + go run cmd/gendoc/main.go From 80f58487fbae870c966eafffd148d0bdfa5bc5d1 Mon Sep 17 00:00:00 2001 From: Oleg Isakov Date: Wed, 28 Jan 2026 12:33:06 +0200 Subject: [PATCH 3/3] add generated docs to release assets --- .goreleaser.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index cf91493..ce3f9da 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -2,6 +2,10 @@ version: 2 before: hooks: - go mod tidy + - go run cmd/gendoc/main.go + - mkdir -p dist + - zip -r dist/srvctl-docs-generated_{{ .Version }}.zip docs/generated + builds: - binary: srvctl flags: @@ -52,3 +56,7 @@ archives: checksum: name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' algorithm: sha256 + +release: + extra_files: + - glob: dist/srvctl-docs-generated_*.zip