Skip to content
Closed
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
docs(man): preserve markdown list/code block structure in DESCRIPTION
  • Loading branch information
Cristian Di Matteo committed May 10, 2026
commit addba5622e22341f8f6049562a507b0c85bb44b1
78 changes: 77 additions & 1 deletion internal/docs/man.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,86 @@ func manPreamble(buf *bytes.Buffer, header *GenManHeader, cmd *cobra.Command, da

if cmd.Long != "" && cmd.Long != cmd.Short {
buf.WriteString("# DESCRIPTION\n")
buf.WriteString(cmd.Long + "\n\n")
buf.WriteString(normalizeMarkdownForMan(cmd.Long) + "\n\n")
}
}

func normalizeMarkdownForMan(input string) string {
lines := strings.Split(input, "\n")
out := make([]string, 0, len(lines))
inFencedCodeBlock := false

for _, line := range lines {
isFence := isFencedCodeDelimiter(line)
if shouldInsertBlockSeparator(line, out, inFencedCodeBlock, isFence) {
out = append(out, "")
}
out = append(out, line)
if isFence {
inFencedCodeBlock = !inFencedCodeBlock
}
}

return strings.Join(out, "\n")
}

func shouldInsertBlockSeparator(line string, previousLines []string, inFencedCodeBlock bool, isFence bool) bool {
if len(previousLines) == 0 {
return false
}
if inFencedCodeBlock {
return false
}
if isFence && len(previousLines) > 0 && isFencedCodeDelimiter(previousLines[len(previousLines)-1]) {
return false
}

if !startsMarkdownBlock(line) {
return false
}

prev := previousLines[len(previousLines)-1]
prevTrimmed := strings.TrimSpace(prev)
if prevTrimmed == "" {
return false
}

// If the previous line already begins a block, adding a separator can break nesting.
return !startsMarkdownBlock(prev)
}

func isFencedCodeDelimiter(line string) bool {
return strings.HasPrefix(strings.TrimLeft(line, " \t"), "```")
}

func startsMarkdownBlock(line string) bool {
trimmed := strings.TrimLeft(line, " \t")
if trimmed == "" {
return false
}

if isFencedCodeDelimiter(line) {
return true
}
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
return true
}
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") {
return true
}
return startsOrderedListItem(trimmed)
}

func startsOrderedListItem(line string) bool {
i := 0
for ; i < len(line); i++ {
if line[i] < '0' || line[i] > '9' {
break
}
}
return i > 0 && i+1 < len(line) && line[i] == '.' && line[i+1] == ' '
}

func manPrintFlags(buf *bytes.Buffer, flags *pflag.FlagSet) {
flags.VisitAll(func(flag *pflag.Flag) {
if len(flag.Deprecated) > 0 || flag.Hidden || flag.Name == "help" {
Expand Down
27 changes: 27 additions & 0 deletions internal/docs/man_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,33 @@ func TestManPrintFlagsHidesShortDeprecated(t *testing.T) {
}
}

func TestNormalizeMarkdownForManAddsMissingBlockSeparators(t *testing.T) {
input := "Intro paragraph:\n- first bullet\n- second bullet\nCode sample:\n```\ngh test --flag\n```"
expected := "Intro paragraph:\n\n- first bullet\n- second bullet\nCode sample:\n\n```\ngh test --flag\n```"
output := normalizeMarkdownForMan(input)
if output != expected {
t.Fatalf("Expected %q, got %q", expected, output)
}
}

func TestRenderManFormatsDescriptionLists(t *testing.T) {
cmd := &cobra.Command{
Use: "test",
Short: "test command",
Long: "Intro paragraph:\n" +
"- first bullet\n" +
"- second bullet",
}

buf := new(bytes.Buffer)
if err := renderMan(cmd, &GenManHeader{}, buf); err != nil {
t.Fatal(err)
}

output := buf.String()
checkStringContains(t, output, ".IP \\(bu 2")
}

func TestGenManTree(t *testing.T) {
c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"}
tmpdir, err := os.MkdirTemp("", "test-gen-man-tree")
Expand Down
Loading