Skip to content

Commit acbbbf2

Browse files
authored
Merge pull request #1722 from tarampampam/feat-tabular-markdown
Feat: Added tabular markdown writer
2 parents 62d51b1 + 9640f32 commit acbbbf2

File tree

8 files changed

+936
-0
lines changed

8 files changed

+936
-0
lines changed

docs.go

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,94 @@ import (
77
"bytes"
88
"fmt"
99
"io"
10+
"os"
11+
"regexp"
1012
"sort"
13+
"strconv"
1114
"strings"
1215
"text/template"
16+
"unicode/utf8"
1317

1418
"github.com/cpuguy83/go-md2man/v2/md2man"
1519
)
1620

21+
// ToTabularMarkdown creates a tabular markdown documentation for the `*App`.
22+
// The function errors if either parsing or writing of the string fails.
23+
func (a *App) ToTabularMarkdown(appPath string) (string, error) {
24+
if appPath == "" {
25+
appPath = "app"
26+
}
27+
28+
const name = "cli"
29+
30+
t, err := template.New(name).Funcs(template.FuncMap{
31+
"join": strings.Join,
32+
}).Parse(MarkdownTabularDocTemplate)
33+
if err != nil {
34+
return "", err
35+
}
36+
37+
var (
38+
w bytes.Buffer
39+
tt tabularTemplate
40+
)
41+
42+
if err = t.ExecuteTemplate(&w, name, cliTabularAppTemplate{
43+
AppPath: appPath,
44+
Name: a.Name,
45+
Description: tt.PrepareMultilineString(a.Description),
46+
Usage: tt.PrepareMultilineString(a.Usage),
47+
UsageText: strings.FieldsFunc(a.UsageText, func(r rune) bool { return r == '\n' }),
48+
ArgsUsage: tt.PrepareMultilineString(a.ArgsUsage),
49+
GlobalFlags: tt.PrepareFlags(a.VisibleFlags()),
50+
Commands: tt.PrepareCommands(a.VisibleCommands(), appPath, "", 0),
51+
}); err != nil {
52+
return "", err
53+
}
54+
55+
return tt.Prettify(w.String()), nil
56+
}
57+
58+
// ToTabularToFileBetweenTags creates a tabular markdown documentation for the `*App` and updates the file between
59+
// the tags in the file. The function errors if either parsing or writing of the string fails.
60+
func (a *App) ToTabularToFileBetweenTags(appPath, filePath string, startEndTags ...string) error {
61+
var start, end = "<!--GENERATED:CLI_DOCS-->", "<!--/GENERATED:CLI_DOCS-->" // default tags
62+
63+
if len(startEndTags) == 2 {
64+
start, end = startEndTags[0], startEndTags[1]
65+
}
66+
67+
// read original file content
68+
content, err := os.ReadFile(filePath)
69+
if err != nil {
70+
return err
71+
}
72+
73+
// generate markdown
74+
md, err := a.ToTabularMarkdown(appPath)
75+
if err != nil {
76+
return err
77+
}
78+
79+
// prepare regexp to replace content between start and end tags
80+
re, err := regexp.Compile("(?s)" + regexp.QuoteMeta(start) + "(.*?)" + regexp.QuoteMeta(end))
81+
if err != nil {
82+
return err
83+
}
84+
85+
const comment = "<!-- Documentation inside this block generated by github.com/urfave/cli; DO NOT EDIT -->"
86+
87+
// replace content between start and end tags
88+
updated := re.ReplaceAll(content, []byte(strings.Join([]string{start, comment, md, end}, "\n")))
89+
90+
// write updated content to file
91+
if err = os.WriteFile(filePath, updated, 0664); err != nil {
92+
return err
93+
}
94+
95+
return nil
96+
}
97+
1798
// ToMarkdown creates a markdown string for the `*App`
1899
// The function errors if either parsing or writing of the string fails.
19100
func (a *App) ToMarkdown() (string, error) {
@@ -196,3 +277,237 @@ func prepareUsage(command *Command, usageText string) string {
196277

197278
return usage
198279
}
280+
281+
type (
282+
cliTabularAppTemplate struct {
283+
AppPath string
284+
Name string
285+
Usage string
286+
ArgsUsage string
287+
UsageText []string
288+
Description string
289+
GlobalFlags []cliTabularFlagTemplate
290+
Commands []cliTabularCommandTemplate
291+
}
292+
293+
cliTabularCommandTemplate struct {
294+
AppPath string
295+
Name string
296+
Aliases []string
297+
Usage string
298+
ArgsUsage string
299+
UsageText []string
300+
Description string
301+
Category string
302+
Flags []cliTabularFlagTemplate
303+
SubCommands []cliTabularCommandTemplate
304+
Level uint
305+
}
306+
307+
cliTabularFlagTemplate struct {
308+
Name string
309+
Aliases []string
310+
Usage string
311+
TakesValue bool
312+
Default string
313+
EnvVars []string
314+
}
315+
)
316+
317+
// tabularTemplate is a struct for the tabular template preparation.
318+
type tabularTemplate struct{}
319+
320+
// PrepareCommands converts CLI commands into a structs for the rendering.
321+
func (tt tabularTemplate) PrepareCommands(commands []*Command, appPath, parentCommandName string, level uint) []cliTabularCommandTemplate {
322+
var result = make([]cliTabularCommandTemplate, 0, len(commands))
323+
324+
for _, cmd := range commands {
325+
var command = cliTabularCommandTemplate{
326+
AppPath: appPath,
327+
Name: strings.TrimSpace(strings.Join([]string{parentCommandName, cmd.Name}, " ")),
328+
Aliases: cmd.Aliases,
329+
Usage: tt.PrepareMultilineString(cmd.Usage),
330+
UsageText: strings.FieldsFunc(cmd.UsageText, func(r rune) bool { return r == '\n' }),
331+
ArgsUsage: tt.PrepareMultilineString(cmd.ArgsUsage),
332+
Description: tt.PrepareMultilineString(cmd.Description),
333+
Category: cmd.Category,
334+
Flags: tt.PrepareFlags(cmd.VisibleFlags()),
335+
SubCommands: tt.PrepareCommands( // note: recursive call
336+
cmd.Commands,
337+
appPath,
338+
strings.Join([]string{parentCommandName, cmd.Name}, " "),
339+
level+1,
340+
),
341+
Level: level,
342+
}
343+
344+
result = append(result, command)
345+
}
346+
347+
return result
348+
}
349+
350+
// PrepareFlags converts CLI flags into a structs for the rendering.
351+
func (tt tabularTemplate) PrepareFlags(flags []Flag) []cliTabularFlagTemplate {
352+
var result = make([]cliTabularFlagTemplate, 0, len(flags))
353+
354+
for _, appFlag := range flags {
355+
flag, ok := appFlag.(DocGenerationFlag)
356+
if !ok {
357+
continue
358+
}
359+
360+
var f = cliTabularFlagTemplate{
361+
Usage: tt.PrepareMultilineString(flag.GetUsage()),
362+
EnvVars: flag.GetEnvVars(),
363+
TakesValue: flag.TakesValue(),
364+
Default: flag.GetValue(),
365+
}
366+
367+
if boolFlag, isBool := appFlag.(*BoolFlag); isBool {
368+
f.Default = strconv.FormatBool(boolFlag.Value)
369+
}
370+
371+
for i, name := range flag.Names() {
372+
name = strings.TrimSpace(name)
373+
374+
if i == 0 {
375+
f.Name = "--" + name
376+
377+
continue
378+
}
379+
380+
if len(name) > 1 {
381+
name = "--" + name
382+
} else {
383+
name = "-" + name
384+
}
385+
386+
f.Aliases = append(f.Aliases, name)
387+
}
388+
389+
result = append(result, f)
390+
}
391+
392+
return result
393+
}
394+
395+
// PrepareMultilineString prepares a string (removes line breaks).
396+
func (tabularTemplate) PrepareMultilineString(s string) string {
397+
return strings.TrimRight(
398+
strings.TrimSpace(
399+
strings.ReplaceAll(s, "\n", " "),
400+
),
401+
".\r\n\t",
402+
)
403+
}
404+
405+
func (tabularTemplate) Prettify(s string) string {
406+
var max = func(x, y int) int {
407+
if x > y {
408+
return x
409+
}
410+
return y
411+
}
412+
413+
var b strings.Builder
414+
415+
// search for tables
416+
for _, rawTable := range regexp.MustCompile(`(?m)^(\|[^\n]+\|\r?\n)((?:\|:?-+:?)+\|)(\n(?:\|[^\n]+\|\r?\n?)*)?$`).FindAllString(s, -1) {
417+
var lines = strings.FieldsFunc(rawTable, func(r rune) bool { return r == '\n' })
418+
419+
if len(lines) < 3 { // header, separator, body
420+
continue
421+
}
422+
423+
// parse table into the matrix
424+
var matrix = make([][]string, 0, len(lines))
425+
for _, line := range lines {
426+
items := strings.FieldsFunc(strings.Trim(line, "| "), func(r rune) bool { return r == '|' })
427+
428+
for i := range items {
429+
items[i] = strings.TrimSpace(items[i]) // trim spaces in cells
430+
}
431+
432+
matrix = append(matrix, items)
433+
}
434+
435+
// determine centered columns
436+
var centered = make([]bool, 0, len(matrix[1]))
437+
for _, cell := range matrix[1] {
438+
centered = append(centered, strings.HasPrefix(cell, ":") && strings.HasSuffix(cell, ":"))
439+
}
440+
441+
// calculate max lengths
442+
var lengths = make([]int, len(matrix[0]))
443+
for n, row := range matrix {
444+
for i, cell := range row {
445+
if n == 1 {
446+
continue // skip separator
447+
}
448+
449+
if l := utf8.RuneCountInString(cell); l > lengths[i] {
450+
lengths[i] = l
451+
}
452+
}
453+
}
454+
455+
// format cells
456+
for i, row := range matrix {
457+
for j, cell := range row {
458+
if i == 1 { // is separator
459+
if centered[j] {
460+
b.Reset()
461+
b.WriteRune(':')
462+
b.WriteString(strings.Repeat("-", max(0, lengths[j])))
463+
b.WriteRune(':')
464+
465+
row[j] = b.String()
466+
} else {
467+
row[j] = strings.Repeat("-", max(0, lengths[j]+2))
468+
}
469+
470+
continue
471+
}
472+
473+
var (
474+
cellWidth = utf8.RuneCountInString(cell)
475+
padLeft, padRight = 1, max(1, lengths[j]-cellWidth+1) // align to the left
476+
)
477+
478+
if centered[j] { // is centered
479+
padLeft = max(1, (lengths[j]-cellWidth)/2)
480+
padRight = max(1, lengths[j]-cellWidth-(padLeft-1))
481+
}
482+
483+
b.Reset()
484+
b.WriteString(strings.Repeat(" ", padLeft))
485+
486+
if padLeft+cellWidth+padRight <= lengths[j]+1 {
487+
b.WriteRune(' ') // add an extra space if the cell is not full
488+
}
489+
490+
b.WriteString(cell)
491+
b.WriteString(strings.Repeat(" ", padRight))
492+
493+
row[j] = b.String()
494+
}
495+
}
496+
497+
b.Reset()
498+
499+
for _, row := range matrix { // build new table
500+
b.WriteRune('|')
501+
b.WriteString(strings.Join(row, "|"))
502+
b.WriteRune('|')
503+
b.WriteRune('\n')
504+
}
505+
506+
s = strings.Replace(s, rawTable, b.String(), 1)
507+
}
508+
509+
s = regexp.MustCompile(`\n{2,}`).ReplaceAllString(s, "\n\n") // normalize newlines
510+
s = strings.Trim(s, " \n") // trim spaces and newlines
511+
512+
return s + "\n" // add an extra newline
513+
}

0 commit comments

Comments
 (0)