Skip to content

Commit 850bb4c

Browse files
authored
feat(docs): improve markdown doc generation command (#493)
Enhance the 'azldev docs markdown' command: - Add --include-hidden flag to generate docs for hidden (advanced) commands - Use GenMarkdownTreeCustom for proper cross-linking between pages - Add auto-generated file header to prevent hand-editing - Strip dynamic version string so docs don't churn on every build - Clean stale .md files before regeneration
1 parent f70591d commit 850bb4c

File tree

3 files changed

+207
-15
lines changed

3 files changed

+207
-15
lines changed

internal/app/azldev/cmds/docs/markdown.go

Lines changed: 121 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ package docs
55

66
import (
77
"fmt"
8+
"regexp"
89

910
"github.com/gim-home/azldev-preview/internal/app/azldev"
11+
"github.com/gim-home/azldev-preview/internal/global/opctx"
1012
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
11-
"github.com/spf13/afero"
1213
"github.com/spf13/cobra"
1314
"github.com/spf13/cobra/doc"
1415
)
1516

1617
type GenerateMarkdownOptions struct {
17-
OutputDir string
18+
OutputDir string
19+
IncludeHidden bool
20+
Force bool
1821
}
1922

2023
// Called once when the app is initialized; registers any commands or callbacks with the app.
@@ -29,12 +32,30 @@ func newMarkdownCmd() *cobra.Command {
2932
Use: "markdown",
3033
Aliases: []string{"md"},
3134
Short: "Generates Markdown (.md) docs for this tool",
32-
RunE: func(cmd *cobra.Command, args []string) error {
33-
return GenerateMarkdownDocs(cmd.Root(), &options)
34-
},
35+
Long: `Generate Markdown reference documentation for all azldev CLI commands.
36+
37+
Produces one .md file per command in the output directory. These files are
38+
auto-generated and should not be hand-edited; update the Cobra command
39+
definitions in Go source code instead, then re-run this command.`,
40+
Example: ` # Generate docs into the user guide
41+
azldev docs markdown -o docs/user/reference/cli/
42+
43+
# Include hidden (advanced) commands
44+
azldev docs markdown -o docs/user/reference/cli/ --include-hidden
45+
46+
# Overwrite an existing non-empty output directory
47+
azldev docs markdown -o docs/user/reference/cli/ -f`,
3548
}
3649

50+
cmd.RunE = azldev.RunFuncWithoutRequiredConfig(
51+
func(env *azldev.Env) (interface{}, error) {
52+
return nil, GenerateMarkdownDocs(env.FS(), cmd.Root(), &options)
53+
})
54+
3755
cmd.Flags().StringVarP(&options.OutputDir, "output-dir", "o", "", "directory markdown files will be written to")
56+
cmd.Flags().BoolVarP(&options.Force, "force", "f", false,
57+
"delete and recreate the output directory if it already exists")
58+
cmd.Flags().BoolVar(&options.IncludeHidden, "include-hidden", false, "include hidden commands in the generated docs")
3859

3960
// NOTE: we ignore errors because we don't expect that they could fail at runtime, and because this function
4061
// is expected to be infallible.
@@ -46,17 +67,107 @@ func newMarkdownCmd() *cobra.Command {
4667
return cmd
4768
}
4869

49-
// Generates markdown documentation for the given command tree.
50-
func GenerateMarkdownDocs(rootCmd *cobra.Command, options *GenerateMarkdownOptions) error {
51-
// Make sure the directory exists.
52-
err := fileutils.MkdirAll(afero.NewOsFs(), options.OutputDir)
70+
const generatedFileHeader = `<!--- DO NOT EDIT: auto-generated by "azldev docs markdown" -->` + "\n\n"
71+
72+
// filePrepender returns a function that prepends the auto-generated header to each generated doc file.
73+
func filePrepender(_ string) string {
74+
return generatedFileHeader
75+
}
76+
77+
// linkHandler converts cobra doc filenames to relative markdown file links.
78+
// Cobra passes in filenames like "azldev_component.md"; since each command gets
79+
// its own file we link directly to that file rather than using a fragment anchor.
80+
func linkHandler(name string) string {
81+
return name
82+
}
83+
84+
// versionSuffix matches a trailing version string like " v1.2.3" or " v0.0.0-20260228210254-10ca5c6a64fb".
85+
var versionSuffix = regexp.MustCompile(` v\S+$`)
86+
87+
// stripVersionFromShort removes the trailing version string from the root command's
88+
// Short description so that generated docs don't change with every build.
89+
func stripVersionFromShort(short string) string {
90+
return versionSuffix.ReplaceAllString(short, "")
91+
}
92+
93+
// setHiddenRecursive sets or clears the [cobra.Command.Hidden] flag on a command and all its children.
94+
func setHiddenRecursive(cmd *cobra.Command, hidden bool) {
95+
cmd.Hidden = hidden
96+
97+
for _, child := range cmd.Commands() {
98+
setHiddenRecursive(child, hidden)
99+
}
100+
}
101+
102+
// CheckOutputDir verifies the output directory state before generation.
103+
// If the directory exists and is non-empty, it either removes it (when Force is set)
104+
// or returns an actionable error suggesting --force / -f.
105+
func CheckOutputDir(fs opctx.FS, options *GenerateMarkdownOptions) error {
106+
dirExists, err := fileutils.DirExists(fs, options.OutputDir)
107+
if err != nil {
108+
return fmt.Errorf("failed to check output directory %q:\n%w", options.OutputDir, err)
109+
}
110+
111+
if !dirExists {
112+
return nil
113+
}
114+
115+
empty, err := fileutils.IsDirEmpty(fs, options.OutputDir)
116+
if err != nil {
117+
return fmt.Errorf("failed to check if output directory %q is empty:\n%w", options.OutputDir, err)
118+
}
119+
120+
if empty {
121+
return nil
122+
}
123+
124+
if options.Force {
125+
if err := fs.RemoveAll(options.OutputDir); err != nil {
126+
return fmt.Errorf("failed to clean output directory %q:\n%w", options.OutputDir, err)
127+
}
128+
129+
return nil
130+
}
131+
132+
return fmt.Errorf(
133+
"output directory %q already exists and is not empty;\n"+
134+
"use --force / -f to delete and recreate it",
135+
options.OutputDir)
136+
}
137+
138+
// GenerateMarkdownDocs generates markdown documentation for the given command tree.
139+
func GenerateMarkdownDocs(fs opctx.FS, rootCmd *cobra.Command, options *GenerateMarkdownOptions) error {
140+
// Strip the dynamic version string from the root command's Short description and disable
141+
// the auto-generated date footer so that generated docs don't churn on every build.
142+
origShort := rootCmd.Short
143+
rootCmd.Short = stripVersionFromShort(origShort)
144+
rootCmd.DisableAutoGenTag = true
145+
146+
defer func() {
147+
rootCmd.Short = origShort
148+
rootCmd.DisableAutoGenTag = false
149+
}()
150+
151+
// Check the output directory state and handle force semantics.
152+
err := CheckOutputDir(fs, options)
153+
if err != nil {
154+
return err
155+
}
156+
157+
// Make sure the directory exists (it may have been removed by checkOutputDir, or may not exist yet).
158+
err = fileutils.MkdirAll(fs, options.OutputDir)
53159
if err != nil {
54160
return fmt.Errorf("failed to ensure output directory %q exists:\n%w", options.OutputDir, err)
55161
}
56162

163+
if options.IncludeHidden {
164+
// Unhide all commands so they appear in the generated docs.
165+
setHiddenRecursive(rootCmd, false)
166+
}
167+
57168
// NOTE: This function can't work with our [opctx.FS] filesystem abstraction, but there's not much
58169
// we can do about that.
59-
err = doc.GenMarkdownTree(rootCmd, options.OutputDir)
170+
err = doc.GenMarkdownTreeCustom(rootCmd, options.OutputDir, filePrepender, linkHandler)
60171
if err != nil {
61172
return fmt.Errorf("failed to generate markdown docs:\n%w", err)
62173
}

internal/app/azldev/cmds/docs/markdown_test.go

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,100 @@ import (
88
"testing"
99

1010
"github.com/gim-home/azldev-preview/internal/app/azldev/cmds/docs"
11+
"github.com/gim-home/azldev-preview/internal/global/testctx"
12+
"github.com/gim-home/azldev-preview/internal/utils/fileperms"
13+
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
14+
"github.com/spf13/afero"
1115
"github.com/spf13/cobra"
16+
"github.com/stretchr/testify/assert"
1217
"github.com/stretchr/testify/require"
1318
)
1419

15-
func TestGenerateMarkdownDocs(t *testing.T) {
16-
// Construct a test command.
17-
cmd := &cobra.Command{
20+
// newTestCommand creates a minimal cobra command for testing doc generation.
21+
func newTestCommand() *cobra.Command {
22+
return &cobra.Command{
1823
Use: "test",
1924
Short: "A test command",
2025
}
26+
}
27+
28+
func TestGenerateMarkdownDocs(t *testing.T) {
29+
// This integration test uses the host FS because cobra's doc.GenMarkdownTreeCustom
30+
// writes directly to the real filesystem.
31+
ctx := testctx.NewCtx(testctx.WithHostFS())
2132

2233
// Select an output dir that won't yet exist. This lets us make sure it will get created.
2334
tempDir := t.TempDir()
2435
outputDir := filepath.Join(tempDir, "docs")
2536

26-
err := docs.GenerateMarkdownDocs(cmd, &docs.GenerateMarkdownOptions{
37+
err := docs.GenerateMarkdownDocs(ctx.FS(), newTestCommand(), &docs.GenerateMarkdownOptions{
2738
OutputDir: outputDir,
2839
})
2940

3041
require.NoError(t, err)
31-
require.DirExists(t, outputDir)
42+
43+
exists, err := fileutils.DirExists(ctx.FS(), outputDir)
44+
require.NoError(t, err)
45+
assert.True(t, exists, "Expected output directory to exist")
3246

3347
matches, err := filepath.Glob(filepath.Join(outputDir, "*.md"))
3448
require.NoError(t, err)
3549
require.NotEmpty(t, matches, "Expected at least one markdown file to be generated")
3650
}
51+
52+
func TestCheckOutputDir_NonExistentDir(t *testing.T) {
53+
testFS := afero.NewMemMapFs()
54+
55+
err := docs.CheckOutputDir(testFS, &docs.GenerateMarkdownOptions{
56+
OutputDir: "/does/not/exist",
57+
})
58+
59+
require.NoError(t, err)
60+
}
61+
62+
func TestCheckOutputDir_EmptyDirWithoutForce(t *testing.T) {
63+
testFS := afero.NewMemMapFs()
64+
65+
require.NoError(t, fileutils.MkdirAll(testFS, "/output"))
66+
67+
err := docs.CheckOutputDir(testFS, &docs.GenerateMarkdownOptions{
68+
OutputDir: "/output",
69+
Force: false,
70+
})
71+
72+
require.NoError(t, err)
73+
}
74+
75+
func TestCheckOutputDir_NonEmptyDirWithoutForce(t *testing.T) {
76+
testFS := afero.NewMemMapFs()
77+
78+
require.NoError(t, fileutils.MkdirAll(testFS, "/output"))
79+
require.NoError(t, fileutils.WriteFile(testFS, "/output/existing.txt", []byte("hello"), fileperms.PrivateFile))
80+
81+
err := docs.CheckOutputDir(testFS, &docs.GenerateMarkdownOptions{
82+
OutputDir: "/output",
83+
Force: false,
84+
})
85+
86+
require.Error(t, err)
87+
assert.Contains(t, err.Error(), "--force")
88+
}
89+
90+
func TestCheckOutputDir_NonEmptyDirWithForce(t *testing.T) {
91+
testFS := afero.NewMemMapFs()
92+
93+
require.NoError(t, fileutils.MkdirAll(testFS, "/output"))
94+
require.NoError(t, fileutils.WriteFile(testFS, "/output/existing.txt", []byte("hello"), fileperms.PrivateFile))
95+
96+
err := docs.CheckOutputDir(testFS, &docs.GenerateMarkdownOptions{
97+
OutputDir: "/output",
98+
Force: true,
99+
})
100+
101+
require.NoError(t, err)
102+
103+
// The directory should have been removed.
104+
exists, err := fileutils.DirExists(testFS, "/output")
105+
require.NoError(t, err)
106+
assert.False(t, exists, "Expected output directory to be removed by --force")
107+
}

scenario/__snapshots__/TestMCPServerMode_1.snap.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,16 @@
262262
"description": "dry run only (do not take action)",
263263
"type": "boolean"
264264
},
265+
"force": {
266+
"default": false,
267+
"description": "delete and recreate the output directory if it already exists",
268+
"type": "boolean"
269+
},
270+
"include-hidden": {
271+
"default": false,
272+
"description": "include hidden commands in the generated docs",
273+
"type": "boolean"
274+
},
265275
"network-retries": {
266276
"default": 3,
267277
"description": "maximum number of attempts for network operations (minimum 1)",

0 commit comments

Comments
 (0)