Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Added tabular markdown writer #1722

Merged
merged 28 commits into from
May 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b857483
🚀 feat(docs.go): add support for generating tabular markdown document…
tarampampam Apr 14, 2023
763d715
🐛 fix(docs.go): split UsageText into an array of strings
tarampampam Apr 14, 2023
bfe4046
📝 docs(godoc-current.txt): add ToTabularMarkdown method to App struct
tarampampam Apr 14, 2023
02295d9
📝 docs(godoc-v3.x.txt): add ToTabularMarkdown method to App struct
tarampampam Apr 14, 2023
2167107
🔨 refactor(docs.go): remove unused TabularOption type and WithTabular…
tarampampam Apr 14, 2023
c45d216
🐛 fix(docs_test.go): add error handling for file close operation
tarampampam Apr 14, 2023
b046679
🐛 fix(docs_test.go): ignore windows line endings in file content
tarampampam Apr 14, 2023
015e176
🔧 chore(docs_test.go): change comment to lowercase 'ignore' and remov…
tarampampam Apr 14, 2023
c2ec633
🐛 fix(docs_test.go): fix typo in comment, add space before 'line endi…
tarampampam Apr 14, 2023
f46c8f5
🐛 fix(docs_test.go): use bytes.Replace instead of bytes.ReplaceAll to…
tarampampam Apr 14, 2023
7396688
🐛 fix(docs_test.go): trim whitespaces and line endings after replacin…
tarampampam Apr 14, 2023
d42ae6d
🐛 fix(docs_test.go): remove unnecessary trimming of bytes in bytes.Re…
tarampampam Apr 14, 2023
daf2ae3
🐛 fix(docs_test.go): remove unnecessary second argument in bytes.Repl…
tarampampam Apr 14, 2023
260031b
🔥 refactor(docs_test.go): remove unused bytes package import
tarampampam Apr 14, 2023
8940782
🔨 refactor(docs_test.go): remove commented out code
tarampampam Apr 14, 2023
012763e
🐛 fix(docs_test.go): replace windows line endings with unix line endings
tarampampam Apr 14, 2023
a0c1d80
🐛 fix(docs_test.go): replace line ending byte sequence to fix test fa…
tarampampam Apr 14, 2023
ebf4e51
🔥 refactor(docs_test.go): remove unused bytes package import
tarampampam Apr 14, 2023
1865722
🐛 fix(docs_test.go): normalize newlines in file content before comparing
tarampampam Apr 14, 2023
33ea5a2
🐛 fix(docs_test.go): refactor normalizeNewlines function to remove du…
tarampampam Apr 14, 2023
e2824a8
🐛 fix(docs_test.go): add comment to clarify the purpose of normalizin…
tarampampam Apr 14, 2023
aa63bac
🔧 chore(docs_test.go): update comment to clarify the purpose of the f…
tarampampam Apr 14, 2023
d36393b
🐛 fix(docs_test.go): add comment to clarify the purpose of normalizin…
tarampampam Apr 14, 2023
05cf386
🧪 test(docs_test.go): add test for ToTabularMarkdown with custom app …
tarampampam May 1, 2023
949f45e
🧪 test(docs_test.go): add test case for ToTabularMarkdown with empty …
tarampampam May 1, 2023
d945e8d
🚨 test(docs_test.go): add test case for ToTabularMarkdownFailed funct…
tarampampam May 1, 2023
d8caf8b
ArgsUsage in next line
tarampampam May 1, 2023
9640f32
🐛 fix(docs_test.go): rename TestToTabularMarkdownFull to TestToTabula…
tarampampam May 1, 2023
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
315 changes: 315 additions & 0 deletions docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,94 @@ import (
"bytes"
"fmt"
"io"
"os"
"regexp"
"sort"
"strconv"
"strings"
"text/template"
"unicode/utf8"

"github.com/cpuguy83/go-md2man/v2/md2man"
)

// ToTabularMarkdown creates a tabular markdown documentation for the `*App`.
// The function errors if either parsing or writing of the string fails.
func (a *App) ToTabularMarkdown(appPath string) (string, error) {
if appPath == "" {
appPath = "app"
}

const name = "cli"

t, err := template.New(name).Funcs(template.FuncMap{
"join": strings.Join,
}).Parse(MarkdownTabularDocTemplate)
if err != nil {
return "", err
}

var (
w bytes.Buffer
tt tabularTemplate
)

if err = t.ExecuteTemplate(&w, name, cliTabularAppTemplate{
AppPath: appPath,
Name: a.Name,
Description: tt.PrepareMultilineString(a.Description),
Usage: tt.PrepareMultilineString(a.Usage),
UsageText: strings.FieldsFunc(a.UsageText, func(r rune) bool { return r == '\n' }),
ArgsUsage: tt.PrepareMultilineString(a.ArgsUsage),
GlobalFlags: tt.PrepareFlags(a.VisibleFlags()),
Commands: tt.PrepareCommands(a.VisibleCommands(), appPath, "", 0),
}); err != nil {
return "", err
dearchap marked this conversation as resolved.
Show resolved Hide resolved
}

return tt.Prettify(w.String()), nil
}

// ToTabularToFileBetweenTags creates a tabular markdown documentation for the `*App` and updates the file between
// the tags in the file. The function errors if either parsing or writing of the string fails.
func (a *App) ToTabularToFileBetweenTags(appPath, filePath string, startEndTags ...string) error {
var start, end = "<!--GENERATED:CLI_DOCS-->", "<!--/GENERATED:CLI_DOCS-->" // default tags

if len(startEndTags) == 2 {
start, end = startEndTags[0], startEndTags[1]
}

// read original file content
content, err := os.ReadFile(filePath)
if err != nil {
return err
}

// generate markdown
md, err := a.ToTabularMarkdown(appPath)
if err != nil {
return err
}

// prepare regexp to replace content between start and end tags
re, err := regexp.Compile("(?s)" + regexp.QuoteMeta(start) + "(.*?)" + regexp.QuoteMeta(end))
if err != nil {
return err
}

const comment = "<!-- Documentation inside this block generated by github.com/urfave/cli; DO NOT EDIT -->"

// replace content between start and end tags
updated := re.ReplaceAll(content, []byte(strings.Join([]string{start, comment, md, end}, "\n")))

// write updated content to file
if err = os.WriteFile(filePath, updated, 0664); err != nil {
return err
}

return nil
}

// ToMarkdown creates a markdown string for the `*App`
// The function errors if either parsing or writing of the string fails.
func (a *App) ToMarkdown() (string, error) {
Expand Down Expand Up @@ -196,3 +277,237 @@ func prepareUsage(command *Command, usageText string) string {

return usage
}

type (
cliTabularAppTemplate struct {
AppPath string
Name string
Usage string
ArgsUsage string
UsageText []string
Description string
GlobalFlags []cliTabularFlagTemplate
Commands []cliTabularCommandTemplate
}

cliTabularCommandTemplate struct {
AppPath string
Name string
Aliases []string
Usage string
ArgsUsage string
UsageText []string
Description string
Category string
Flags []cliTabularFlagTemplate
SubCommands []cliTabularCommandTemplate
Level uint
}

cliTabularFlagTemplate struct {
Name string
Aliases []string
Usage string
TakesValue bool
Default string
EnvVars []string
}
)

// tabularTemplate is a struct for the tabular template preparation.
type tabularTemplate struct{}

// PrepareCommands converts CLI commands into a structs for the rendering.
func (tt tabularTemplate) PrepareCommands(commands []*Command, appPath, parentCommandName string, level uint) []cliTabularCommandTemplate {
var result = make([]cliTabularCommandTemplate, 0, len(commands))

for _, cmd := range commands {
var command = cliTabularCommandTemplate{
AppPath: appPath,
Name: strings.TrimSpace(strings.Join([]string{parentCommandName, cmd.Name}, " ")),
Aliases: cmd.Aliases,
Usage: tt.PrepareMultilineString(cmd.Usage),
UsageText: strings.FieldsFunc(cmd.UsageText, func(r rune) bool { return r == '\n' }),
ArgsUsage: tt.PrepareMultilineString(cmd.ArgsUsage),
Description: tt.PrepareMultilineString(cmd.Description),
Category: cmd.Category,
Flags: tt.PrepareFlags(cmd.VisibleFlags()),
SubCommands: tt.PrepareCommands( // note: recursive call
cmd.Commands,
appPath,
strings.Join([]string{parentCommandName, cmd.Name}, " "),
level+1,
),
Level: level,
}

result = append(result, command)
}

return result
}

// PrepareFlags converts CLI flags into a structs for the rendering.
func (tt tabularTemplate) PrepareFlags(flags []Flag) []cliTabularFlagTemplate {
var result = make([]cliTabularFlagTemplate, 0, len(flags))

for _, appFlag := range flags {
flag, ok := appFlag.(DocGenerationFlag)
if !ok {
continue
}

var f = cliTabularFlagTemplate{
Usage: tt.PrepareMultilineString(flag.GetUsage()),
EnvVars: flag.GetEnvVars(),
TakesValue: flag.TakesValue(),
Default: flag.GetValue(),
}

if boolFlag, isBool := appFlag.(*BoolFlag); isBool {
f.Default = strconv.FormatBool(boolFlag.Value)
}

for i, name := range flag.Names() {
name = strings.TrimSpace(name)

if i == 0 {
f.Name = "--" + name

continue
}

if len(name) > 1 {
name = "--" + name
} else {
name = "-" + name
}

f.Aliases = append(f.Aliases, name)
}

result = append(result, f)
}

return result
}

// PrepareMultilineString prepares a string (removes line breaks).
func (tabularTemplate) PrepareMultilineString(s string) string {
return strings.TrimRight(
strings.TrimSpace(
strings.ReplaceAll(s, "\n", " "),
),
".\r\n\t",
)
}

func (tabularTemplate) Prettify(s string) string {
var max = func(x, y int) int {
if x > y {
return x
}
return y
}

var b strings.Builder

// search for tables
for _, rawTable := range regexp.MustCompile(`(?m)^(\|[^\n]+\|\r?\n)((?:\|:?-+:?)+\|)(\n(?:\|[^\n]+\|\r?\n?)*)?$`).FindAllString(s, -1) {
var lines = strings.FieldsFunc(rawTable, func(r rune) bool { return r == '\n' })

if len(lines) < 3 { // header, separator, body
continue
}

// parse table into the matrix
var matrix = make([][]string, 0, len(lines))
for _, line := range lines {
items := strings.FieldsFunc(strings.Trim(line, "| "), func(r rune) bool { return r == '|' })

for i := range items {
items[i] = strings.TrimSpace(items[i]) // trim spaces in cells
}

matrix = append(matrix, items)
}

// determine centered columns
var centered = make([]bool, 0, len(matrix[1]))
for _, cell := range matrix[1] {
centered = append(centered, strings.HasPrefix(cell, ":") && strings.HasSuffix(cell, ":"))
}

// calculate max lengths
var lengths = make([]int, len(matrix[0]))
for n, row := range matrix {
for i, cell := range row {
if n == 1 {
continue // skip separator
}

if l := utf8.RuneCountInString(cell); l > lengths[i] {
lengths[i] = l
}
}
}

// format cells
for i, row := range matrix {
for j, cell := range row {
if i == 1 { // is separator
if centered[j] {
b.Reset()
b.WriteRune(':')
b.WriteString(strings.Repeat("-", max(0, lengths[j])))
b.WriteRune(':')

row[j] = b.String()
} else {
row[j] = strings.Repeat("-", max(0, lengths[j]+2))
}

continue
}

var (
cellWidth = utf8.RuneCountInString(cell)
padLeft, padRight = 1, max(1, lengths[j]-cellWidth+1) // align to the left
)

if centered[j] { // is centered
padLeft = max(1, (lengths[j]-cellWidth)/2)
padRight = max(1, lengths[j]-cellWidth-(padLeft-1))
}

b.Reset()
b.WriteString(strings.Repeat(" ", padLeft))

if padLeft+cellWidth+padRight <= lengths[j]+1 {
b.WriteRune(' ') // add an extra space if the cell is not full
}

b.WriteString(cell)
b.WriteString(strings.Repeat(" ", padRight))

row[j] = b.String()
}
}

b.Reset()

for _, row := range matrix { // build new table
b.WriteRune('|')
b.WriteString(strings.Join(row, "|"))
b.WriteRune('|')
b.WriteRune('\n')
}

s = strings.Replace(s, rawTable, b.String(), 1)
}

s = regexp.MustCompile(`\n{2,}`).ReplaceAllString(s, "\n\n") // normalize newlines
s = strings.Trim(s, " \n") // trim spaces and newlines

return s + "\n" // add an extra newline
}
Loading