Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist
.idea/
cp.out
.DS_Store
docs/generated
8 changes: 8 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -52,3 +56,7 @@ archives:
checksum:
name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS'
algorithm: sha256

release:
extra_files:
- glob: dist/srvctl-docs-generated_*.zip
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.PHONY: docs

deps:
go mod tidy
go mod vendor
Expand Down Expand Up @@ -42,3 +44,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
35 changes: 35 additions & 0 deletions cmd/gendoc/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
Expand Down
280 changes: 280 additions & 0 deletions internal/docgen/generator.go
Original file line number Diff line number Diff line change
@@ -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-<commandPath>/
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-<commandPath>/
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
}
Loading