Skip to content
Open
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,36 @@ greet [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...]

````

## Available doc types

- `docs.ToMarkdown(cmd)`
- `docs.ToTabularMarkdown(cmd, appPath)`
- `docs.ToMan(cmd)`
- `docs.ToManWithSection(cmd, section)`
Comment on lines +88 to +91
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe replace all that with docs.Render(template, vars), with template being docs.TpltMarkdown, docs.TpltMarkdownTabular, etc. And provide good documentation about docs.Vars template variables. Just thinking..

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That might be cleaner but would be a breaking API change

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can go through deprecation cycle until v4 is ready https://github.com/urfave/cli-docs/releases ?

Or don't remove anything at all, like Go does. This project is not size critical.

Copy link
Copy Markdown
Author

@tuunit tuunit May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion are three ways to build a library of this sort.

  1. As currently done a separate constructor/render function per functionality -> this doesn't scale well and introduces unnecessary code size and maintaince
  2. Single constructor like docs.Render(Options) -> really clean but breaks the existing API surface
  3. Builder pattern like docs.WithOptioms(Options).Render() or methods per options docs.Template(template).DocumentPerCommand().Render()

I decided to go with the third way because it can be introduced easily without breaking the existing API surface while extending the preexisting API as well. So it was a win win in my eyes.

Obviously we could go with the second approach as well, as it is quite clean to have a single options struct. Even with that we could mark the existing methods as deprecated and replace the internal logic with a proper Render call and the right options.

Let me know what you would prefer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not an expert, so I am just throwing an opinion. Constructor pattern is not unusual in Go, but it is usually used in stateful libs. Here I don't see the need to store template or the error as a state. Why maintain a long call stack if it can be reduced to a single docs.Render(template, vars) call? And vars can be the state perhaps derived from *cli? But then maybe it can work with both cli/v2 and cli/v3? I have no answers. I know only that with the extending the API, we don't break anything.

I would wait for @dearchap to chime in before pivoting. Maybe it is fine after all. )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer a constructor pattern as well by the by but rather than docs.Render(template, vars) I would do docs.Render(vars) and treat the template just as any other option field in a struct like:

type RenderOptions struct {
    Template string
    TemplateFile string
    DocumentPerCommand bool
    Type TemplateType
    ...
}

Copy link
Copy Markdown
Author

@tuunit tuunit May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@abitrolly
If it wasn't clear from my responses. I do like this type of interaction and feedback. I appreciate it a lot when people take time looking into such seemingly mundane topics such as this PR


## Custom templates

The package ships with embedded templates in `MarkdownDocTemplate` and
`MarkdownTabularDocTemplate`, and it also lets you render with your own
template string or `.gotmpl` file via a call.

```go
customMarkdown, err := docs.TemplateFile("docs/custom.md.gotmpl").ToMarkdown(app)
if err != nil {
panic(err)
}

customTabular, err := docs.Template(
`# {{ .Name }}

{{ range .Commands }}- {{ .Name }}
{{ end }}`,
).ToTabularMarkdown(app, "greet")
if err != nil {
panic(err)
}
```

## Examples
Some examples of the cli generated using this markdown
* https://woodpecker-ci.org/docs/cli
95 changes: 87 additions & 8 deletions docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,44 @@ func tracef(format string, a ...any) {
)
}

// DocsBuilder renders documentation with a caller-provided template string or
// template file.
type DocsBuilder struct {
docTemplate string
err error
}

// Template returns a builder that renders documentation with the provided Go
// template string.
func Template(docTemplate string) DocsBuilder {
return DocsBuilder{docTemplate: docTemplate}
}

// TemplateFile returns a builder that renders documentation with the provided
// Go template file.
func TemplateFile(templatePath string) DocsBuilder {
docTemplate, err := readTemplateFile(templatePath)
return DocsBuilder{
docTemplate: docTemplate,
err: err,
}
}

// ToTabularMarkdown creates a tabular markdown documentation for
// the `*cli.Command`. The function errors if either parsing or
// writing of the string fails.
func ToTabularMarkdown(cmd *cli.Command, appPath string) (string, error) {
return Template(MarkdownTabularDocTemplate).ToTabularMarkdown(cmd, appPath)
}

// ToTabularMarkdown creates a tabular markdown documentation for
// the `*cli.Command` using the builder template. The function errors if either
// parsing or writing of the string fails.
func (b DocsBuilder) ToTabularMarkdown(cmd *cli.Command, appPath string) (string, error) {
if b.err != nil {
return "", b.err
}

if appPath == "" {
appPath = "app"
}
Expand All @@ -68,7 +102,7 @@ func ToTabularMarkdown(cmd *cli.Command, appPath string) (string, error) {

t, err := template.New(name).Funcs(template.FuncMap{
"join": strings.Join,
}).Parse(MarkdownTabularDocTemplate)
}).Parse(b.docTemplate)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -137,8 +171,19 @@ func ToTabularToFileBetweenTags(cmd *cli.Command, appPath, filePath string, star
// ToMarkdown creates a markdown string for the `*cli.Command`
// The function errors if either parsing or writing of the string fails.
func ToMarkdown(cmd *cli.Command) (string, error) {
return Template(MarkdownDocTemplate).ToMarkdown(cmd)
}

// ToMarkdown creates a markdown string for the `*cli.Command` using the
// builder template. The function errors if either parsing or writing of the
// string fails.
func (b DocsBuilder) ToMarkdown(cmd *cli.Command) (string, error) {
if b.err != nil {
return "", b.err
}

var w bytes.Buffer
if err := writeDocTemplate(cmd, &w, 0); err != nil {
if err := writeDocTemplate(cmd, &w, 0, b.docTemplate); err != nil {
return "", err
}
return w.String(), nil
Expand All @@ -148,8 +193,19 @@ func ToMarkdown(cmd *cli.Command) (string, error) {
// `*cli.Command` The function errors if either parsing or writing
// of the string fails.
func ToManWithSection(cmd *cli.Command, sectionNumber int) (string, error) {
return Template(MarkdownDocTemplate).ToManWithSection(cmd, sectionNumber)
}

// ToManWithSection creates a man page string with section number for the
// `*cli.Command` using the builder template. The function errors if either
// parsing or writing of the string fails.
func (b DocsBuilder) ToManWithSection(cmd *cli.Command, sectionNumber int) (string, error) {
if b.err != nil {
return "", b.err
}

var w bytes.Buffer
if err := writeDocTemplate(cmd, &w, sectionNumber); err != nil {
if err := writeDocTemplate(cmd, &w, sectionNumber, b.docTemplate); err != nil {
return "", err
}
man := md2man.Render(w.Bytes())
Expand All @@ -159,8 +215,14 @@ func ToManWithSection(cmd *cli.Command, sectionNumber int) (string, error) {
// ToMan creates a man page string for the `*cli.Command`
// The function errors if either parsing or writing of the string fails.
func ToMan(cmd *cli.Command) (string, error) {
man, err := ToManWithSection(cmd, 8)
return man, err
return ToManWithSection(cmd, 8)
}

// ToMan creates a man page string for the `*cli.Command` using the builder
// template. The function errors if either parsing or writing of the string
// fails.
func (b DocsBuilder) ToMan(cmd *cli.Command) (string, error) {
return b.ToManWithSection(cmd, 8)
}

type cliCommandTemplate struct {
Expand All @@ -171,11 +233,11 @@ type cliCommandTemplate struct {
SynopsisArgs []string
}

func writeDocTemplate(cmd *cli.Command, w io.Writer, sectionNum int) error {
tracef("using MarkdownDocTemplate starting %[1]q", string([]byte(MarkdownDocTemplate)[0:8]))
func writeDocTemplate(cmd *cli.Command, w io.Writer, sectionNum int, docTemplate string) error {
tracef("using MarkdownDocTemplate starting %[1]q", previewTemplate(docTemplate))

const name = "cli"
t, err := template.New(name).Parse(MarkdownDocTemplate)
t, err := template.New(name).Parse(docTemplate)
if err != nil {
return err
}
Expand All @@ -189,6 +251,23 @@ func writeDocTemplate(cmd *cli.Command, w io.Writer, sectionNum int) error {
})
}

func readTemplateFile(templatePath string) (string, error) {
data, err := os.ReadFile(templatePath)
if err != nil {
return "", err
}

return string(data), nil
}

func previewTemplate(docTemplate string) string {
if len(docTemplate) <= 8 {
return docTemplate
}

return docTemplate[:8]
}

func prepareCommands(commands []*cli.Command, level int) []string {
var coms []string
for _, command := range commands {
Expand Down
94 changes: 94 additions & 0 deletions docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"github.com/cpuguy83/go-md2man/v2/md2man"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
Expand Down Expand Up @@ -190,6 +191,32 @@ func TestToMarkdownFull(t *testing.T) {
expectFileContent(t, "testdata/expected-doc-full.md", res)
}

func TestBuilderToMarkdown(t *testing.T) {
cmd := buildExtendedTestCommand(t)

res, err := Template(`{{ .Command.Name }}|{{ .SectionNum }}|{{ len .Commands }}|{{ len .GlobalArgs }}|{{ len .SynopsisArgs }}`).ToMarkdown(cmd)

require.NoError(t, err)
require.Equal(t, "greet|0|6|4|4", res)
}

func TestBuilderToMarkdownFromFile(t *testing.T) {
cmd := buildExtendedTestCommand(t)

tmpFile, err := os.CreateTemp("", "*.gotmpl")
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(tmpFile.Name()) })

_, err = tmpFile.WriteString(`{{ .Command.Name }} from {{ .Command.Usage }}`)
require.NoError(t, err)
require.NoError(t, tmpFile.Close())

res, err := TemplateFile(tmpFile.Name()).ToMarkdown(cmd)

require.NoError(t, err)
require.Equal(t, "greet from Some app", res)
}

func TestToTabularMarkdown(t *testing.T) {
app := buildExtendedTestCommand(t)

Expand All @@ -212,6 +239,32 @@ func TestToTabularMarkdown(t *testing.T) {
})
}

func TestBuilderToTabularMarkdown(t *testing.T) {
app := buildExtendedTestCommand(t)

res, err := Template(`{{ .AppPath }}|{{ .Name }}|{{ join (index .Commands 0).Aliases "," }}`).ToTabularMarkdown(app, "/usr/local/bin")

require.NoError(t, err)
require.Equal(t, "/usr/local/bin|greet|c\n", res)
}

func TestBuilderToTabularMarkdownFromFile(t *testing.T) {
app := buildExtendedTestCommand(t)

tmpFile, err := os.CreateTemp("", "*.gotmpl")
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(tmpFile.Name()) })

_, err = tmpFile.WriteString(`{{ .AppPath }}|{{ (index .Commands 1).Name }}`)
require.NoError(t, err)
require.NoError(t, tmpFile.Close())

res, err := TemplateFile(tmpFile.Name()).ToTabularMarkdown(app, "/usr/local/bin")

require.NoError(t, err)
require.Equal(t, "/usr/local/bin|info\n", res)
}

func TestToTabularMarkdownFailed(t *testing.T) {
tpl := MarkdownTabularDocTemplate
t.Cleanup(func() { MarkdownTabularDocTemplate = tpl })
Expand Down Expand Up @@ -389,6 +442,19 @@ func TestToMan(t *testing.T) {
expectFileContent(t, "testdata/expected-doc-full.man", res)
}

func TestBuilderToMan(t *testing.T) {
app := buildExtendedTestCommand(t)
tpl := `# NAME

{{ .Command.Name }}
`

res, err := Template(tpl).ToMan(app)

require.NoError(t, err)
require.Equal(t, string(md2man.Render([]byte("# NAME\n\ngreet\n"))), res)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

md2man generates string, so replacing that with the real expected output saves cycles and makes it possible to switch tools in future.

}

func TestToManParseError(t *testing.T) {
app := buildExtendedTestCommand(t)

Expand All @@ -401,6 +467,34 @@ func TestToManParseError(t *testing.T) {
require.ErrorContains(t, err, "template: cli:1: unclosed action")
}

func TestTemplateFileMissing(t *testing.T) {
app := buildExtendedTestCommand(t)

_, err := TemplateFile("/missing/template.gotmpl").ToMarkdown(app)

require.ErrorIs(t, err, fs.ErrNotExist)
}

func TestBuilderToManWithSectionFromFile(t *testing.T) {
app := buildExtendedTestCommand(t)

tmpFile, err := os.CreateTemp("", "*.gotmpl")
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(tmpFile.Name()) })

_, err = tmpFile.WriteString(`# NAME

{{ .Command.Name }} ({{ .SectionNum }})
`)
require.NoError(t, err)
require.NoError(t, tmpFile.Close())

res, err := TemplateFile(tmpFile.Name()).ToManWithSection(app, 5)

require.NoError(t, err)
require.Equal(t, string(md2man.Render([]byte("# NAME\n\ngreet (5)\n"))), res)
}

func TestToManWithSection(t *testing.T) {
cmd := buildExtendedTestCommand(t)

Expand Down
Loading