From 8763c8ac058e7078e7ce5f4514f10865e57d9c6f Mon Sep 17 00:00:00 2001 From: Gianluca Arbezzano Date: Fri, 20 Feb 2026 15:26:01 +0100 Subject: [PATCH] feat: implement transformation mechanism to improve generated cli docs The cobra generated markdown is not ideal and it needs to be tweaked a bit in order to look right in datum.net but cobra does not offer a hook to apply transformations during generation. For this reason I had to implement a transformation mechanism as second step. There are two transformations asked by Felix: 1. Lowercase link "SEE ALSO" to "See also" 2. Move `h1` header to `h3` --- .../cmd/docs/generate-cli-documentation.go | 118 +++++++++++++++++- .../docs/generate-cli-documentation_test.go | 104 +++++++++++++++ 2 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/docs/generate-cli-documentation_test.go diff --git a/internal/cmd/docs/generate-cli-documentation.go b/internal/cmd/docs/generate-cli-documentation.go index fb1ac05..827c9aa 100644 --- a/internal/cmd/docs/generate-cli-documentation.go +++ b/internal/cmd/docs/generate-cli-documentation.go @@ -1,7 +1,10 @@ package docs import ( + "bufio" "fmt" + "io" + "os" "path" "path/filepath" "strings" @@ -49,13 +52,122 @@ title: "%s" Example: generateDocExample, Long: generateDocLong, RunE: func(cmd *cobra.Command, args []string) error { - err := doc.GenMarkdownTreeCustom(root, outputDir, filePrepender, linkHandler) - if err != nil { + if err := doc.GenMarkdownTreeCustom(root, outputDir, filePrepender, linkHandler); err != nil { return err } - return nil + return downscaleMarkdownHeadersInDir(outputDir) }, } cmd.Flags().StringVar(&outputDir, "output-dir", "/tmp/datumctl-generated-doc", "Directory to use to output the generated documentation") return cmd } + +func downscaleMarkdownHeadersInDir(root string) error { + return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || filepath.Ext(path) != ".md" { + return nil + } + return downscaleMarkdownHeadersInFile(path) + }) +} + +func downscaleMarkdownHeadersInFile(filename string) error { + in, err := os.Open(filename) + if err != nil { + return err + } + defer in.Close() + + tmp, err := os.CreateTemp(filepath.Dir(filename), ".md-transform-*.tmp") + if err != nil { + return err + } + defer func() { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + }() + + reader := bufio.NewReader(in) + writer := bufio.NewWriter(tmp) + inFence := false + var fenceMarker string + + for { + line, err := reader.ReadString('\n') + isEOF := err == io.EOF + if err != nil && !isEOF { + return err + } + + trimmed := strings.TrimRight(line, "\r\n") + fence := strings.TrimLeft(trimmed, " \t") + if strings.HasPrefix(fence, "```") || strings.HasPrefix(fence, "~~~") { + marker := fence[:3] + if !inFence { + inFence = true + fenceMarker = marker + } else if fenceMarker == marker { + inFence = false + fenceMarker = "" + } + } else if !inFence { + line = replaceH1WithH3(line) + line = normalizeSeeAlsoLine(line) + } + + if _, werr := writer.WriteString(line); werr != nil { + return werr + } + + if isEOF { + break + } + } + + if err := writer.Flush(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := in.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), filename) +} + +func replaceH1WithH3(line string) string { + trimmed := strings.TrimLeft(line, " \t") + if len(trimmed) == 0 || trimmed[0] != '#' { + return line + } + + hashes := 0 + for hashes < len(trimmed) && trimmed[hashes] == '#' { + hashes++ + } + if hashes != 1 { + return line + } + if len(trimmed) <= hashes || trimmed[hashes] != ' ' { + return line + } + + prefixLen := len(line) - len(trimmed) + return line[:prefixLen] + "###" + trimmed[hashes:] +} + +func normalizeSeeAlsoLine(line string) string { + trimmed := strings.TrimLeft(line, " \t") + if strings.HasPrefix(trimmed, "### SEE ALSO") { + prefixLen := len(line) - len(trimmed) + return line[:prefixLen] + "### See also" + trimmed[len("### SEE ALSO"):] + } + if strings.Contains(line, "[SEE ALSO](") { + return strings.ReplaceAll(line, "[SEE ALSO](", "[See also](") + } + return line +} diff --git a/internal/cmd/docs/generate-cli-documentation_test.go b/internal/cmd/docs/generate-cli-documentation_test.go new file mode 100644 index 0000000..1c28606 --- /dev/null +++ b/internal/cmd/docs/generate-cli-documentation_test.go @@ -0,0 +1,104 @@ +package docs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReplaceH1WithH3(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "h1 to h3", in: "# Title\n", want: "### Title\n"}, + {name: "h2 unchanged", in: "## Title\n", want: "## Title\n"}, + {name: "h6 unchanged", in: "###### Title\n", want: "###### Title\n"}, + {name: "no space unchanged", in: "#Title\n", want: "#Title\n"}, + {name: "indent preserved", in: " # Title\n", want: " ### Title\n"}, + {name: "not header", in: "Title\n", want: "Title\n"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := replaceH1WithH3(test.in); got != test.want { + t.Fatalf("replaceH1WithH3(%q) = %q, want %q", test.in, got, test.want) + } + }) + } +} + +func TestNormalizeSeeAlsoLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "heading", in: "### SEE ALSO\n", want: "### See also\n"}, + {name: "heading with indent", in: " ### SEE ALSO\n", want: " ### See also\n"}, + {name: "link label", in: "* [SEE ALSO](link)\n", want: "* [See also](link)\n"}, + {name: "other", in: "### Other\n", want: "### Other\n"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := normalizeSeeAlsoLine(test.in); got != test.want { + t.Fatalf("normalizeSeeAlsoLine(%q) = %q, want %q", test.in, got, test.want) + } + }) + } +} + +func TestDownscaleMarkdownHeadersInFile_RespectsFences(t *testing.T) { + t.Parallel() + + content := "" + + "# A\n" + + "### SEE ALSO\n" + + "```bash\n" + + "# Not\n" + + "```\n" + + "## B\n" + + "~~~\n" + + "### Not2\n" + + "~~~\n" + + dir := t.TempDir() + file := filepath.Join(dir, "test.md") + if err := os.WriteFile(file, []byte(content), 0644); err != nil { + t.Fatalf("write test file: %v", err) + } + + if err := downscaleMarkdownHeadersInFile(file); err != nil { + t.Fatalf("downscaleMarkdownHeadersInFile: %v", err) + } + + got, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read transformed file: %v", err) + } + + want := "" + + "### A\n" + + "### See also\n" + + "```bash\n" + + "# Not\n" + + "```\n" + + "## B\n" + + "~~~\n" + + "### Not2\n" + + "~~~\n" + + if string(got) != want { + t.Fatalf("unexpected transformed content:\n%s", string(got)) + } +}