Skip to content

Commit b857483

Browse files
committed
🚀 feat(docs.go): add support for generating tabular markdown documentation
✅ test(docs_test.go): add tests for generating tabular markdown documentation ✨ feat(template.go): add MarkdownTabularDocTemplate to generate a markdown table for command flags and environment variables
1 parent 62d51b1 commit b857483

File tree

4 files changed

+448
-0
lines changed

4 files changed

+448
-0
lines changed

‎docs.go

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,70 @@ import (
77
"bytes"
88
"fmt"
99
"io"
10+
"regexp"
1011
"sort"
12+
"strconv"
1113
"strings"
1214
"text/template"
15+
"unicode/utf8"
1316

1417
"github.com/cpuguy83/go-md2man/v2/md2man"
1518
)
1619

20+
type (
21+
tabularOptions struct {
22+
appPath string
23+
}
24+
25+
TabularOption func(*tabularOptions)
26+
)
27+
28+
// WithTabularAppPath allows to override the default app path.
29+
func WithTabularAppPath(path string) TabularOption {
30+
return func(o *tabularOptions) { o.appPath = path }
31+
}
32+
33+
// ToTabularMarkdown creates a tabular markdown documentation for the `*App`.
34+
// The function errors if either parsing or writing of the string fails.
35+
func (a *App) ToTabularMarkdown(opts ...TabularOption) (string, error) {
36+
var o = tabularOptions{
37+
appPath: "app",
38+
}
39+
40+
for _, opt := range opts {
41+
opt(&o)
42+
}
43+
44+
const name = "cli"
45+
46+
t, err := template.New(name).Funcs(template.FuncMap{
47+
"join": strings.Join,
48+
}).Parse(MarkdownTabularDocTemplate)
49+
if err != nil {
50+
return "", err
51+
}
52+
53+
var (
54+
w bytes.Buffer
55+
tt tabularTemplate
56+
)
57+
58+
if err = t.ExecuteTemplate(&w, name, cliTabularAppTemplate{
59+
AppPath: o.appPath,
60+
Name: a.Name,
61+
Description: tt.PrepareMultilineString(a.Description),
62+
Usage: tt.PrepareMultilineString(a.Usage),
63+
UsageText: tt.PrepareMultilineString(a.UsageText),
64+
ArgsUsage: tt.PrepareMultilineString(a.ArgsUsage),
65+
GlobalFlags: tt.PrepareFlags(a.VisibleFlags()),
66+
Commands: tt.PrepareCommands(a.VisibleCommands(), o.appPath, "", 0),
67+
}); err != nil {
68+
return "", err
69+
}
70+
71+
return tt.Prettify(w.String()), nil
72+
}
73+
1774
// ToMarkdown creates a markdown string for the `*App`
1875
// The function errors if either parsing or writing of the string fails.
1976
func (a *App) ToMarkdown() (string, error) {
@@ -196,3 +253,204 @@ func prepareUsage(command *Command, usageText string) string {
196253

197254
return usage
198255
}
256+
257+
type (
258+
cliTabularAppTemplate struct {
259+
AppPath string
260+
Name string
261+
Usage, UsageText, ArgsUsage string
262+
Description string
263+
GlobalFlags []cliTabularFlagTemplate
264+
Commands []cliTabularCommandTemplate
265+
}
266+
267+
cliTabularCommandTemplate struct {
268+
AppPath string
269+
Name string
270+
Aliases []string
271+
Usage, UsageText, ArgsUsage string
272+
Description string
273+
Category string
274+
Flags []cliTabularFlagTemplate
275+
SubCommands []cliTabularCommandTemplate
276+
Level uint
277+
}
278+
279+
cliTabularFlagTemplate struct {
280+
Name string
281+
Aliases []string
282+
Usage string
283+
TakesValue bool
284+
Default string
285+
EnvVars []string
286+
}
287+
)
288+
289+
// tabularTemplate is a struct for the tabular template preparation.
290+
type tabularTemplate struct{}
291+
292+
// PrepareCommands converts CLI commands into a structs for the rendering.
293+
func (tt tabularTemplate) PrepareCommands(commands []*Command, appPath, parentCommandName string, level uint) []cliTabularCommandTemplate {
294+
var result = make([]cliTabularCommandTemplate, 0, len(commands))
295+
296+
for _, cmd := range commands {
297+
var command = cliTabularCommandTemplate{
298+
AppPath: appPath,
299+
Name: strings.TrimSpace(strings.Join([]string{parentCommandName, cmd.Name}, " ")),
300+
Aliases: cmd.Aliases,
301+
Usage: tt.PrepareMultilineString(cmd.Usage),
302+
UsageText: tt.PrepareMultilineString(cmd.UsageText),
303+
ArgsUsage: tt.PrepareMultilineString(cmd.ArgsUsage),
304+
Description: tt.PrepareMultilineString(cmd.Description),
305+
Category: cmd.Category,
306+
Flags: tt.PrepareFlags(cmd.VisibleFlags()),
307+
SubCommands: tt.PrepareCommands( // note: recursive call
308+
cmd.Commands,
309+
appPath,
310+
strings.Join([]string{parentCommandName, cmd.Name}, " "),
311+
level+1,
312+
),
313+
Level: level,
314+
}
315+
316+
result = append(result, command)
317+
}
318+
319+
return result
320+
}
321+
322+
// PrepareFlags converts CLI flags into a structs for the rendering.
323+
func (tt tabularTemplate) PrepareFlags(flags []Flag) []cliTabularFlagTemplate {
324+
var result = make([]cliTabularFlagTemplate, 0, len(flags))
325+
326+
for _, appFlag := range flags {
327+
flag, ok := appFlag.(DocGenerationFlag)
328+
if !ok {
329+
continue
330+
}
331+
332+
var f = cliTabularFlagTemplate{
333+
Usage: tt.PrepareMultilineString(flag.GetUsage()),
334+
EnvVars: flag.GetEnvVars(),
335+
TakesValue: flag.TakesValue(),
336+
Default: flag.GetValue(),
337+
}
338+
339+
if boolFlag, isBool := appFlag.(*BoolFlag); isBool {
340+
f.Default = strconv.FormatBool(boolFlag.Value)
341+
}
342+
343+
for i, name := range flag.Names() {
344+
name = strings.TrimSpace(name)
345+
346+
if i == 0 {
347+
f.Name = "--" + name
348+
349+
continue
350+
}
351+
352+
if len(name) > 1 {
353+
name = "--" + name
354+
} else {
355+
name = "-" + name
356+
}
357+
358+
f.Aliases = append(f.Aliases, name)
359+
}
360+
361+
result = append(result, f)
362+
}
363+
364+
return result
365+
}
366+
367+
// PrepareMultilineString prepares a string (removes line breaks).
368+
func (tabularTemplate) PrepareMultilineString(s string) string {
369+
return strings.TrimRight(
370+
strings.TrimSpace(
371+
strings.ReplaceAll(s, "\n", " "),
372+
),
373+
".\r\n\t",
374+
)
375+
}
376+
377+
func (tabularTemplate) Prettify(s string) string {
378+
s = regexp.MustCompile(`\n{2,}`).ReplaceAllString(s, "\n\n") // normalize newlines
379+
s = strings.Trim(s, " \n") // trim spaces and newlines
380+
381+
// search for tables
382+
for _, rawTable := range regexp.MustCompile(`(?m)^(\|[^\n]+\|\r?\n)((?:\|:?-+:?)+\|)(\n(?:\|[^\n]+\|\r?\n?)*)?$`).FindAllString(s, -1) {
383+
var lines = strings.FieldsFunc(rawTable, func(r rune) bool { return r == '\n' })
384+
385+
if len(lines) < 3 { // header, separator, body
386+
continue
387+
}
388+
389+
// parse table into the matrix
390+
var matrix = make([][]string, 0, len(lines))
391+
for _, line := range lines {
392+
items := strings.FieldsFunc(strings.Trim(line, "| "), func(r rune) bool { return r == '|' })
393+
394+
for i := range items {
395+
items[i] = strings.TrimSpace(items[i]) // trim spaces in cells
396+
}
397+
398+
matrix = append(matrix, items)
399+
}
400+
401+
// determine centered columns
402+
var centered = make([]bool, 0, len(matrix[1]))
403+
for _, cell := range matrix[1] {
404+
centered = append(centered, strings.HasPrefix(cell, ":") && strings.HasSuffix(cell, ":"))
405+
}
406+
407+
// calculate max lengths
408+
var lengths = make([]int, len(matrix[0]))
409+
const padding = 2 // 2 spaces for padding
410+
for _, row := range matrix {
411+
for i, cell := range row {
412+
if len(cell) > lengths[i]-padding {
413+
lengths[i] = utf8.RuneCountInString(cell) + padding
414+
}
415+
}
416+
}
417+
418+
// format cells
419+
for i, row := range matrix {
420+
for j, cell := range row {
421+
if i == 1 { // is separator
422+
if centered[j] {
423+
cell = ":" + strings.Repeat("-", lengths[j]-2) + ":"
424+
} else {
425+
cell = strings.Repeat("-", lengths[j]+1)
426+
}
427+
}
428+
429+
var (
430+
padLeft, padRight = 1, 1
431+
cellWidth = utf8.RuneCountInString(cell)
432+
)
433+
434+
if centered[j] { // is centered
435+
padLeft = (lengths[j] - cellWidth) / 2
436+
padRight = lengths[j] - cellWidth - padLeft
437+
} else if i == 1 { // is header
438+
padLeft, padRight = 0, 0
439+
} else { // align to the left
440+
padRight = lengths[j] - cellWidth
441+
}
442+
443+
row[j] = strings.Repeat(" ", padLeft) + cell + strings.Repeat(" ", padRight)
444+
}
445+
}
446+
447+
var newTable string
448+
for _, row := range matrix { // build new table
449+
newTable += "|" + strings.Join(row, "|") + "|\n"
450+
}
451+
452+
s = strings.Replace(s, rawTable, newTable, 1)
453+
}
454+
455+
return s + "\n" // add an extra newline
456+
}

‎docs_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ func TestToMarkdownFull(t *testing.T) {
2020
expectFileContent(t, "testdata/expected-doc-full.md", res)
2121
}
2222

23+
func TestToTabularMarkdownFull(t *testing.T) {
24+
// Given
25+
app := testApp()
26+
27+
// When
28+
res, err := app.ToTabularMarkdown()
29+
30+
// Then
31+
expect(t, err, nil)
32+
expectFileContent(t, "testdata/expected-tabular-markdown-full.md", res)
33+
}
34+
2335
func TestToMarkdownNoFlags(t *testing.T) {
2436
// Given
2537
app := testApp()

‎template.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,74 @@ var MarkdownDocTemplate = `{{if gt .SectionNum 0}}% {{ .App.Name }} {{ .SectionN
128128
{{ range $v := .Commands }}
129129
{{ $v }}{{ end }}{{ end }}`
130130

131+
var MarkdownTabularDocTemplate = `{{ define "flags" }}
132+
| Name | Description | Default value | Environment variables |
133+
|------|-------------|:-------------:|:---------------------:|
134+
{{ range $flag := . -}}
135+
{{- /**/ -}} | ` + "`" + `{{ $flag.Name }}{{ if $flag.TakesValue }}="…"{{ end }}` + "`" + ` {{ if $flag.Aliases }}(` + "`" + `{{ join $flag.Aliases "` + "`, `" + `" }}` + "`" + `) {{ end }}
136+
{{- /**/ -}} | {{ $flag.Usage }}
137+
{{- /**/ -}} | {{ if $flag.Default }}` + "`" + `{{ $flag.Default }}` + "`" + `{{ end }}
138+
{{- /**/ -}} | {{ if $flag.EnvVars }}` + "`" + `{{ join $flag.EnvVars "` + "`, `" + `" }}` + "`" + `{{ else }}*none*{{ end }}
139+
{{- /**/ -}} |
140+
{{ end }}
141+
{{ end }}
142+
143+
{{ define "command" }}
144+
### ` + "`" + `{{ .Name }}` + "`" + ` {{ if gt .Level 0 }}sub{{ end }}command{{ if .Aliases }} (aliases: ` + "`" + `{{ join .Aliases "` + "`, `" + `" }}` + "`" + `){{ end }}
145+
{{ if .Usage }}
146+
{{ .Usage }}.
147+
{{ end }}
148+
{{ if .UsageText }}
149+
> {{ .UsageText }}.
150+
{{ end }}
151+
{{ if .Description }}
152+
{{ .Description }}.
153+
{{ end }}
154+
Usage:
155+
156+
` + "```" + `bash
157+
$ {{ .AppPath }} [GLOBAL FLAGS] {{ .Name }}{{ if .Flags }} [COMMAND FLAGS]{{ end }} {{ if .ArgsUsage }}{{ .ArgsUsage }}{{ else }}[ARGUMENTS...]{{ end }}
158+
` + "```" + `
159+
160+
{{ if .Flags -}}
161+
The following flags are supported:
162+
{{ template "flags" .Flags }}
163+
{{ end -}}
164+
165+
{{ if .SubCommands -}}
166+
{{ range $subCmd := .SubCommands -}}
167+
{{ template "command" $subCmd }}
168+
{{ end -}}
169+
{{ end -}}
170+
{{ end }}
171+
172+
## CLI interface{{ if .Name }} - {{ .Name }}{{ end }}
173+
174+
{{ if .Description }}{{ .Description }}.
175+
{{ end }}
176+
{{ if .Usage }}{{ .Usage }}.
177+
{{ end }}
178+
{{ if .UsageText }}
179+
> {{ .UsageText }}.
180+
{{ end }}
181+
Usage:
182+
183+
` + "```" + `bash
184+
$ {{ .AppPath }}{{ if .GlobalFlags }} [GLOBAL FLAGS]{{ end }} [COMMAND] [COMMAND FLAGS] {{ if .ArgsUsage }}{{ .ArgsUsage }}{{ else }}[ARGUMENTS...]{{ end }}
185+
` + "```" + `
186+
187+
{{ if .GlobalFlags }}
188+
Global flags:
189+
190+
{{ template "flags" .GlobalFlags }}
191+
192+
{{ end -}}
193+
{{ if .Commands -}}
194+
{{ range $cmd := .Commands -}}
195+
{{ template "command" $cmd }}
196+
{{ end }}
197+
{{- end }}`
198+
131199
var FishCompletionTemplate = `# {{ .App.Name }} fish shell completion
132200
133201
function __fish_{{ .App.Name }}_no_subcommand --description 'Test if there has been any subcommand yet'

0 commit comments

Comments
 (0)