Skip to content

Commit 622fd00

Browse files
lennessyyclaude
andcommitted
feat: support subdirectory splitting for grouped command docs
Add a `-group` flag to gen-docs that specifies command names whose subcommands should each get their own file in a subdirectory rather than all being combined into a single file. This is needed for the cloud CLI extension, which has ~130 commands that would produce an unmanageably large single page. Usage: go run ./cmd/gen-docs \ -input internal/temporalcli/commands.yaml \ -input path/to/cloud-cli/commands.yml \ -group cloud \ -output dist/docs This produces cloud/namespace.mdx, cloud/user.mdx, cloud/apikey.mdx, etc. alongside the existing flat files. Existing non-grouped commands are unaffected. Also fixes the encodeJSONExample regex to catch standalone JSON objects in single quotes (e.g., '{"key":"value"}'), not just key=value patterns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8f916d5 commit 622fd00

3 files changed

Lines changed: 170 additions & 13 deletions

File tree

cmd/gen-docs/main.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"github.com/temporalio/cli/internal/commandsgen"
1111
)
1212

13-
// stringSlice implements flag.Value to support multiple -input flags
13+
// stringSlice implements flag.Value to support multiple flags of the same name
1414
type stringSlice []string
1515

1616
func (s *stringSlice) String() string {
@@ -30,12 +30,14 @@ func main() {
3030

3131
func run() error {
3232
var (
33-
outputDir string
34-
inputFiles stringSlice
33+
outputDir string
34+
inputFiles stringSlice
35+
groupedNames stringSlice
3536
)
3637

3738
flag.Var(&inputFiles, "input", "Input YAML file (can be specified multiple times)")
3839
flag.StringVar(&outputDir, "output", ".", "Output directory for docs")
40+
flag.Var(&groupedNames, "group", "Command name whose subcommands get separate files in a subdirectory (can be specified multiple times)")
3941
flag.Parse()
4042

4143
if len(inputFiles) == 0 {
@@ -60,13 +62,19 @@ func run() error {
6062
return fmt.Errorf("failed parsing YAML: %w", err)
6163
}
6264

63-
docs, err := commandsgen.GenerateDocsFiles(cmds)
65+
docs, err := commandsgen.GenerateDocsFiles(cmds, groupedNames)
6466
if err != nil {
6567
return fmt.Errorf("failed generating docs: %w", err)
6668
}
6769

6870
for filename, content := range docs {
6971
filePath := filepath.Join(outputDir, filename+".mdx")
72+
// Create subdirectories if the filename contains a path separator (e.g., "cloud/namespace")
73+
if dir := filepath.Dir(filePath); dir != "" {
74+
if err := os.MkdirAll(dir, 0755); err != nil {
75+
return fmt.Errorf("failed creating directory %s: %w", dir, err)
76+
}
77+
}
7078
if err := os.WriteFile(filePath, content, 0644); err != nil {
7179
return fmt.Errorf("failed writing %s: %w", filePath, err)
7280
}

internal/commandsgen/docs.go

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,27 @@ import (
88
"strings"
99
)
1010

11-
func GenerateDocsFiles(commands Commands) (map[string][]byte, error) {
11+
// GenerateDocsFiles generates documentation files from parsed commands.
12+
// groupedNames specifies command names whose subcommands should each get their
13+
// own file in a subdirectory (e.g., passing "cloud" produces cloud/namespace.mdx,
14+
// cloud/user.mdx, etc. instead of a single cloud.mdx).
15+
func GenerateDocsFiles(commands Commands, groupedNames []string) (map[string][]byte, error) {
1216
optionSetMap := make(map[string]OptionSets)
1317
for i, optionSet := range commands.OptionSets {
1418
optionSetMap[optionSet.Name] = commands.OptionSets[i]
1519
}
1620

21+
groupedParents := make(map[string]bool)
22+
for _, name := range groupedNames {
23+
groupedParents[name] = true
24+
}
25+
1726
w := &docWriter{
1827
fileMap: make(map[string]*bytes.Buffer),
1928
optionSetMap: optionSetMap,
2029
allCommands: commands.CommandList,
2130
globalFlagsMap: make(map[string]map[string]Option),
31+
groupedParents: groupedParents,
2232
}
2333

2434
// sorted ascending by full name of command (activity complete, batch list, etc)
@@ -45,13 +55,34 @@ type docWriter struct {
4555
optionSetMap map[string]OptionSets
4656
optionsStack [][]Option
4757
globalFlagsMap map[string]map[string]Option // fileName -> optionName -> Option
58+
groupedParents map[string]bool // depth-1 command names that use subdirectory splitting
4859
}
4960

5061
func (c *Command) writeDoc(w *docWriter) error {
5162
w.processOptions(c)
5263

53-
// If this is a root command, write a new file
5464
depth := c.depth()
65+
66+
// Check if this command itself is the grouped root, or belongs to one.
67+
// A grouped root command (e.g., "cloud") is skipped - it has no standalone file.
68+
// Its direct children (e.g., "cloud namespace") each get their own file in a subdirectory.
69+
if w.groupedParents[c.FullName] {
70+
// This is the grouped root - skip it
71+
return nil
72+
}
73+
if depth >= 1 {
74+
// Walk up the tree to find if any ancestor is a grouped parent
75+
parts := strings.Split(c.FullName, " ")
76+
for i := len(parts) - 1; i >= 1; i-- {
77+
ancestor := strings.Join(parts[:i], " ")
78+
if w.groupedParents[ancestor] {
79+
c.writeGroupedDoc(w, ancestor)
80+
return nil
81+
}
82+
}
83+
}
84+
85+
// Standard (non-grouped) handling
5586
if depth == 1 {
5687
w.writeCommand(c)
5788
} else if depth > 1 {
@@ -60,6 +91,22 @@ func (c *Command) writeDoc(w *docWriter) error {
6091
return nil
6192
}
6293

94+
func (c *Command) writeGroupedDoc(w *docWriter, groupRoot string) {
95+
// How many parts does the group root have?
96+
groupDepth := len(strings.Split(groupRoot, " "))
97+
parts := strings.Split(c.FullName, " ")
98+
relativeDepth := len(parts) - groupDepth
99+
100+
switch {
101+
case relativeDepth == 1:
102+
// Direct child of grouped root (e.g., "cloud namespace") - new file
103+
w.writeGroupedCommand(c, groupRoot)
104+
case relativeDepth > 1:
105+
// Deeper subcommand (e.g., "cloud namespace get") - append to parent file
106+
w.writeGroupedSubcommand(c, groupRoot)
107+
}
108+
}
109+
63110
func (w *docWriter) writeCommand(c *Command) {
64111
fileName := c.fileName()
65112
w.fileMap[fileName] = &bytes.Buffer{}
@@ -88,6 +135,107 @@ func (w *docWriter) writeCommand(c *Command) {
88135
w.fileMap[fileName].WriteString("Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand.\n\n")
89136
}
90137

138+
// groupedFileName returns the file path for a command within a grouped parent.
139+
// For example, with groupRoot "cloud" and command "cloud namespace", returns "cloud/namespace".
140+
func groupedFileName(c *Command, groupRoot string) string {
141+
groupParts := strings.Split(groupRoot, " ")
142+
cmdParts := strings.Split(c.FullName, " ")
143+
// The file is named after the first part after the group root
144+
if len(cmdParts) <= len(groupParts) {
145+
return ""
146+
}
147+
return strings.Join(groupParts, "-") + "/" + cmdParts[len(groupParts)]
148+
}
149+
150+
func (w *docWriter) writeGroupedCommand(c *Command, groupRoot string) {
151+
fileName := groupedFileName(c, groupRoot)
152+
groupParts := strings.Split(groupRoot, " ")
153+
cmdParts := strings.Split(c.FullName, " ")
154+
leafName := cmdParts[len(groupParts)]
155+
fullCmdName := strings.Join(cmdParts, " ")
156+
157+
w.fileMap[fileName] = &bytes.Buffer{}
158+
w.fileMap[fileName].WriteString("---\n")
159+
w.fileMap[fileName].WriteString("id: " + leafName + "\n")
160+
w.fileMap[fileName].WriteString("title: Temporal CLI " + fullCmdName + " command reference\n")
161+
w.fileMap[fileName].WriteString("sidebar_label: " + leafName + "\n")
162+
w.fileMap[fileName].WriteString("description: " + c.Docs.DescriptionHeader + "\n")
163+
w.fileMap[fileName].WriteString("toc_max_heading_level: 4\n")
164+
165+
w.fileMap[fileName].WriteString("keywords:\n")
166+
for _, keyword := range c.Docs.Keywords {
167+
w.fileMap[fileName].WriteString(" - " + keyword + "\n")
168+
}
169+
w.fileMap[fileName].WriteString("tags:\n")
170+
for _, tag := range c.Docs.Tags {
171+
w.fileMap[fileName].WriteString(" - " + tag + "\n")
172+
}
173+
w.fileMap[fileName].WriteString("---")
174+
w.fileMap[fileName].WriteString("\n\n")
175+
w.fileMap[fileName].WriteString("{/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten.\n")
176+
w.fileMap[fileName].WriteString("This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */}\n\n")
177+
w.fileMap[fileName].WriteString(fmt.Sprintf("This page provides a reference for the `temporal %s` commands. ", fullCmdName))
178+
w.fileMap[fileName].WriteString("The flags applicable to each subcommand are presented in a table within the heading for the subcommand. ")
179+
w.fileMap[fileName].WriteString("Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand.\n\n")
180+
}
181+
182+
func (w *docWriter) writeGroupedSubcommand(c *Command, groupRoot string) {
183+
fileName := groupedFileName(c, groupRoot)
184+
groupParts := strings.Split(groupRoot, " ")
185+
cmdParts := strings.Split(c.FullName, " ")
186+
// Heading depth is relative to the grouped file root
187+
// e.g., "cloud namespace get" with groupRoot "cloud" → relativeDepth 2 → ## heading
188+
relativeDepth := len(cmdParts) - len(groupParts)
189+
prefix := strings.Repeat("#", relativeDepth)
190+
leafParts := cmdParts[len(groupParts)+1:]
191+
leafName := strings.Join(leafParts, "")
192+
193+
w.fileMap[fileName].WriteString(prefix + " " + leafName + "\n\n")
194+
w.fileMap[fileName].WriteString(c.Description + "\n\n")
195+
196+
if w.isLeafCommand(c) {
197+
var options = make([]Option, 0)
198+
var globalOptions = make([]Option, 0)
199+
for i, o := range w.optionsStack {
200+
if i == len(w.optionsStack)-1 {
201+
options = append(options, o...)
202+
} else {
203+
globalOptions = append(globalOptions, o...)
204+
}
205+
}
206+
207+
sort.Slice(options, func(i, j int) bool {
208+
return options[i].Name < options[j].Name
209+
})
210+
211+
if len(options) > 0 {
212+
w.fileMap[fileName].WriteString("Use the following options to change the behavior of this command. ")
213+
w.fileMap[fileName].WriteString("You can also use any of the [global flags](#global-flags) that apply to all subcommands.\n\n")
214+
w.writeGroupedOptionsTable(options, fileName)
215+
} else {
216+
w.fileMap[fileName].WriteString("Use [global flags](#global-flags) to customize the connection to the Temporal Service for this command.\n\n")
217+
}
218+
219+
w.collectGlobalFlags(fileName, globalOptions)
220+
}
221+
}
222+
223+
func (w *docWriter) writeGroupedOptionsTable(options []Option, fileName string) {
224+
if len(options) == 0 {
225+
return
226+
}
227+
228+
buf := w.fileMap[fileName]
229+
230+
buf.WriteString("| Flag | Required | Description |\n")
231+
buf.WriteString("|------|----------|-------------|\n")
232+
233+
for _, o := range options {
234+
w.writeOptionRow(buf, o, false)
235+
}
236+
buf.WriteString("\n")
237+
}
238+
91239
func (w *docWriter) writeSubcommand(c *Command) {
92240
fileName := c.fileName()
93241
prefix := strings.Repeat("#", c.depth())
@@ -255,10 +403,11 @@ func (w *docWriter) isLeafCommand(c *Command) bool {
255403
}
256404

257405
func encodeJSONExample(v string) string {
258-
// example: 'YourKey={"your": "value"}'
259-
// results in an mdx acorn rendering error
260-
// and wrapping in backticks lets it render
261-
re := regexp.MustCompile(`('[a-zA-Z0-9]*={.*}')`)
406+
// JSON objects in single quotes cause MDX acorn rendering errors
407+
// because curly braces are interpreted as JSX expressions.
408+
// Wrapping in backticks makes them render as inline code.
409+
// Matches both 'Key={"value"}' and '{"key": "value"}' patterns.
410+
re := regexp.MustCompile(`('\{.*?\}')`)
262411
v = re.ReplaceAllString(v, "`$1`")
263412
return v
264413
}

internal/commandsgen/parse.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ type (
4545
MaximumArgs int `yaml:"maximum-args"`
4646
IgnoreMissingEnv bool `yaml:"ignores-missing-env"`
4747
SubcommandsOptional bool `yaml:"subcommands-optional"`
48-
Options []Option `yaml:"options"`
49-
OptionSets []string `yaml:"option-sets"`
50-
Docs Docs `yaml:"docs"`
48+
Options []Option `yaml:"options"`
49+
OptionSets []string `yaml:"option-sets"`
50+
Docs Docs `yaml:"docs"`
5151
}
5252

5353
// Docs represents docs-only information that is not used in CLI generation.

0 commit comments

Comments
 (0)