From 8460048ef354ba488a1c373f75eb05f3e6749595 Mon Sep 17 00:00:00 2001 From: Lenny Chen Date: Mon, 13 Apr 2026 17:45:56 -0700 Subject: [PATCH 1/4] feat: add -split flag to gen-docs for subdirectory output Add a `-split` flag 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. With `-split cloud`, gen-docs produces cloud/namespace.mdx, cloud/user.mdx, etc. 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) --- cmd/gen-docs/main.go | 12 ++- internal/commandsgen/docs.go | 155 +++++++++++++++++++++++++++++++++-- 2 files changed, 159 insertions(+), 8 deletions(-) diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 11ea430e0..5dc710f69 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -10,7 +10,7 @@ import ( "github.com/temporalio/cli/internal/commandsgen" ) -// stringSlice implements flag.Value to support multiple -input flags +// stringSlice implements flag.Value to support multiple flags of the same name type stringSlice []string func (s *stringSlice) String() string { @@ -32,10 +32,12 @@ func run() error { var ( outputDir string inputFiles stringSlice + splitNames stringSlice ) flag.Var(&inputFiles, "input", "Input YAML file (can be specified multiple times)") flag.StringVar(&outputDir, "output", ".", "Output directory for docs") + flag.Var(&splitNames, "split", "Command name whose subcommands get separate files in a subdirectory (can be specified multiple times)") flag.Parse() if len(inputFiles) == 0 { @@ -60,13 +62,19 @@ func run() error { return fmt.Errorf("failed parsing YAML: %w", err) } - docs, err := commandsgen.GenerateDocsFiles(cmds) + docs, err := commandsgen.GenerateDocsFiles(cmds, splitNames) if err != nil { return fmt.Errorf("failed generating docs: %w", err) } for filename, content := range docs { filePath := filepath.Join(outputDir, filename+".mdx") + // Create subdirectories if the filename contains a path separator (e.g., "cloud/namespace") + if dir := filepath.Dir(filePath); dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed creating directory %s: %w", dir, err) + } + } if err := os.WriteFile(filePath, content, 0644); err != nil { return fmt.Errorf("failed writing %s: %w", filePath, err) } diff --git a/internal/commandsgen/docs.go b/internal/commandsgen/docs.go index b3857503e..c0ec0d555 100644 --- a/internal/commandsgen/docs.go +++ b/internal/commandsgen/docs.go @@ -8,17 +8,27 @@ import ( "strings" ) -func GenerateDocsFiles(commands Commands) (map[string][]byte, error) { +// GenerateDocsFiles generates documentation files from parsed commands. +// splitNames specifies command names whose subcommands should each get their +// own file in a subdirectory (e.g., passing "cloud" produces cloud/namespace.mdx, +// cloud/user.mdx, etc. instead of a single cloud.mdx). +func GenerateDocsFiles(commands Commands, splitNames []string) (map[string][]byte, error) { optionSetMap := make(map[string]OptionSets) for i, optionSet := range commands.OptionSets { optionSetMap[optionSet.Name] = commands.OptionSets[i] } + splitParents := make(map[string]bool) + for _, name := range splitNames { + splitParents[name] = true + } + w := &docWriter{ fileMap: make(map[string]*bytes.Buffer), optionSetMap: optionSetMap, allCommands: commands.CommandList, globalFlagsMap: make(map[string]map[string]Option), + splitParents: splitParents, } // sorted ascending by full name of command (activity complete, batch list, etc) @@ -45,13 +55,32 @@ type docWriter struct { optionSetMap map[string]OptionSets optionsStack [][]Option globalFlagsMap map[string]map[string]Option // fileName -> optionName -> Option + splitParents map[string]bool // command names that use subdirectory splitting } func (c *Command) writeDoc(w *docWriter) error { w.processOptions(c) - // If this is a root command, write a new file depth := c.depth() + + // A split parent (e.g., "cloud") is skipped — it has no standalone file. + // Its children each get their own file in a subdirectory. + if w.splitParents[c.FullName] { + return nil + } + if depth >= 1 { + // Walk up the tree to find if any ancestor is a split parent + parts := strings.Split(c.FullName, " ") + for i := len(parts) - 1; i >= 1; i-- { + ancestor := strings.Join(parts[:i], " ") + if w.splitParents[ancestor] { + c.writeSplitDoc(w, ancestor) + return nil + } + } + } + + // Standard (non-split) handling if depth == 1 { w.writeCommand(c) } else if depth > 1 { @@ -60,6 +89,21 @@ func (c *Command) writeDoc(w *docWriter) error { return nil } +func (c *Command) writeSplitDoc(w *docWriter, splitRoot string) { + splitDepth := len(strings.Split(splitRoot, " ")) + parts := strings.Split(c.FullName, " ") + relativeDepth := len(parts) - splitDepth + + switch { + case relativeDepth == 1: + // Direct child of split root (e.g., "cloud namespace") — new file + w.writeSplitCommand(c, splitRoot) + case relativeDepth > 1: + // Deeper subcommand (e.g., "cloud namespace get") — append to parent file + w.writeSplitSubcommand(c, splitRoot) + } +} + func (w *docWriter) writeCommand(c *Command) { fileName := c.fileName() w.fileMap[fileName] = &bytes.Buffer{} @@ -88,6 +132,104 @@ func (w *docWriter) writeCommand(c *Command) { w.fileMap[fileName].WriteString("Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand.\n\n") } +// splitFileName returns the file path for a command within a split parent. +// For example, with splitRoot "cloud" and command "cloud namespace", returns "cloud/namespace". +func splitFileName(c *Command, splitRoot string) string { + splitParts := strings.Split(splitRoot, " ") + cmdParts := strings.Split(c.FullName, " ") + if len(cmdParts) <= len(splitParts) { + return "" + } + return strings.Join(splitParts, "-") + "/" + cmdParts[len(splitParts)] +} + +func (w *docWriter) writeSplitCommand(c *Command, splitRoot string) { + fileName := splitFileName(c, splitRoot) + splitParts := strings.Split(splitRoot, " ") + cmdParts := strings.Split(c.FullName, " ") + leafName := cmdParts[len(splitParts)] + fullCmdName := strings.Join(cmdParts, " ") + + w.fileMap[fileName] = &bytes.Buffer{} + w.fileMap[fileName].WriteString("---\n") + w.fileMap[fileName].WriteString("id: " + leafName + "\n") + w.fileMap[fileName].WriteString("title: Temporal CLI " + fullCmdName + " command reference\n") + w.fileMap[fileName].WriteString("sidebar_label: " + leafName + "\n") + w.fileMap[fileName].WriteString("description: " + c.Docs.DescriptionHeader + "\n") + w.fileMap[fileName].WriteString("toc_max_heading_level: 4\n") + + w.fileMap[fileName].WriteString("keywords:\n") + for _, keyword := range c.Docs.Keywords { + w.fileMap[fileName].WriteString(" - " + keyword + "\n") + } + w.fileMap[fileName].WriteString("tags:\n") + for _, tag := range c.Docs.Tags { + w.fileMap[fileName].WriteString(" - " + tag + "\n") + } + w.fileMap[fileName].WriteString("---") + w.fileMap[fileName].WriteString("\n\n") + w.fileMap[fileName].WriteString("{/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten.\n") + w.fileMap[fileName].WriteString("This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */}\n\n") + w.fileMap[fileName].WriteString(fmt.Sprintf("This page provides a reference for the `temporal %s` commands. ", fullCmdName)) + w.fileMap[fileName].WriteString("The flags applicable to each subcommand are presented in a table within the heading for the subcommand. ") + w.fileMap[fileName].WriteString("Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand.\n\n") +} + +func (w *docWriter) writeSplitSubcommand(c *Command, splitRoot string) { + fileName := splitFileName(c, splitRoot) + splitParts := strings.Split(splitRoot, " ") + cmdParts := strings.Split(c.FullName, " ") + relativeDepth := len(cmdParts) - len(splitParts) + prefix := strings.Repeat("#", relativeDepth) + leafParts := cmdParts[len(splitParts)+1:] + leafName := strings.Join(leafParts, "") + + w.fileMap[fileName].WriteString(prefix + " " + leafName + "\n\n") + w.fileMap[fileName].WriteString(c.Description + "\n\n") + + if w.isLeafCommand(c) { + var options = make([]Option, 0) + var globalOptions = make([]Option, 0) + for i, o := range w.optionsStack { + if i == len(w.optionsStack)-1 { + options = append(options, o...) + } else { + globalOptions = append(globalOptions, o...) + } + } + + sort.Slice(options, func(i, j int) bool { + return options[i].Name < options[j].Name + }) + + if len(options) > 0 { + w.fileMap[fileName].WriteString("Use the following options to change the behavior of this command. ") + w.fileMap[fileName].WriteString("You can also use any of the [global flags](#global-flags) that apply to all subcommands.\n\n") + w.writeSplitOptionsTable(options, fileName) + } else { + w.fileMap[fileName].WriteString("Use [global flags](#global-flags) to customize the connection to the Temporal Service for this command.\n\n") + } + + w.collectGlobalFlags(fileName, globalOptions) + } +} + +func (w *docWriter) writeSplitOptionsTable(options []Option, fileName string) { + if len(options) == 0 { + return + } + + buf := w.fileMap[fileName] + + buf.WriteString("| Flag | Required | Description |\n") + buf.WriteString("|------|----------|-------------|\n") + + for _, o := range options { + w.writeOptionRow(buf, o, false) + } + buf.WriteString("\n") +} + func (w *docWriter) writeSubcommand(c *Command) { fileName := c.fileName() prefix := strings.Repeat("#", c.depth()) @@ -255,10 +397,11 @@ func (w *docWriter) isLeafCommand(c *Command) bool { } func encodeJSONExample(v string) string { - // example: 'YourKey={"your": "value"}' - // results in an mdx acorn rendering error - // and wrapping in backticks lets it render - re := regexp.MustCompile(`('[a-zA-Z0-9]*={.*}')`) + // JSON objects in single quotes cause MDX acorn rendering errors + // because curly braces are interpreted as JSX expressions. + // Wrapping in backticks makes them render as inline code. + // Matches both 'Key={"value"}' and '{"key": "value"}' patterns. + re := regexp.MustCompile(`('\{.*?\}')`) v = re.ReplaceAllString(v, "`$1`") return v } From ccf7c098d72a7a66ee9fda1a43866a72cd7e8ec9 Mon Sep 17 00:00:00 2001 From: Lenny Chen Date: Mon, 13 Apr 2026 18:00:10 -0700 Subject: [PATCH 2/4] fix: handle leaf commands in split output Leaf commands (e.g., cloud login) that have no subcommands no longer reference a nonexistent #global-flags anchor. Instead they render their description and options directly on the page. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/commandsgen/docs.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/commandsgen/docs.go b/internal/commandsgen/docs.go index c0ec0d555..7ce1d99cd 100644 --- a/internal/commandsgen/docs.go +++ b/internal/commandsgen/docs.go @@ -170,9 +170,27 @@ func (w *docWriter) writeSplitCommand(c *Command, splitRoot string) { w.fileMap[fileName].WriteString("\n\n") w.fileMap[fileName].WriteString("{/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten.\n") w.fileMap[fileName].WriteString("This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */}\n\n") - w.fileMap[fileName].WriteString(fmt.Sprintf("This page provides a reference for the `temporal %s` commands. ", fullCmdName)) - w.fileMap[fileName].WriteString("The flags applicable to each subcommand are presented in a table within the heading for the subcommand. ") - w.fileMap[fileName].WriteString("Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand.\n\n") + + if w.isLeafCommand(c) { + w.fileMap[fileName].WriteString(fmt.Sprintf("This page provides a reference for the `temporal %s` command.\n\n", fullCmdName)) + w.fileMap[fileName].WriteString(c.Description + "\n\n") + + // Write options directly if any + var options []Option + if len(w.optionsStack) > 0 { + options = append(options, w.optionsStack[len(w.optionsStack)-1]...) + } + sort.Slice(options, func(i, j int) bool { + return options[i].Name < options[j].Name + }) + if len(options) > 0 { + w.writeSplitOptionsTable(options, fileName) + } + } else { + w.fileMap[fileName].WriteString(fmt.Sprintf("This page provides a reference for the `temporal %s` commands. ", fullCmdName)) + w.fileMap[fileName].WriteString("The flags applicable to each subcommand are presented in a table within the heading for the subcommand. ") + w.fileMap[fileName].WriteString("Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand.\n\n") + } } func (w *docWriter) writeSplitSubcommand(c *Command, splitRoot string) { From 0900aad96ea48d35809bc741bafe2ff508dfd0b2 Mon Sep 17 00:00:00 2001 From: Lenny Chen Date: Tue, 14 Apr 2026 14:50:16 -0700 Subject: [PATCH 3/4] feat: generate index page for command reference gen-docs now produces an index.mdx that lists all command reference pages with links, including split commands like cloud/*. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/commandsgen/docs.go | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/commandsgen/docs.go b/internal/commandsgen/docs.go index 7ce1d99cd..5f98b4290 100644 --- a/internal/commandsgen/docs.go +++ b/internal/commandsgen/docs.go @@ -41,6 +41,9 @@ func GenerateDocsFiles(commands Commands, splitNames []string) (map[string][]byt // Write global flags section once at the end of each file w.writeGlobalFlagsSections() + // Generate index page + w.writeIndex(splitParents) + // Format and return var finalMap = make(map[string][]byte) for key, buf := range w.fileMap { @@ -384,6 +387,44 @@ func (w *docWriter) writeGlobalFlagsSections() { } } +func (w *docWriter) writeIndex(splitParents map[string]bool) { + buf := &bytes.Buffer{} + buf.WriteString("---\n") + buf.WriteString("id: index\n") + buf.WriteString("title: Temporal CLI command reference\n") + buf.WriteString("sidebar_label: Overview\n") + buf.WriteString("description: Complete command reference for the Temporal CLI, including the cloud extension.\n") + buf.WriteString("slug: /cli/command-reference\n") + buf.WriteString("toc_max_heading_level: 4\n") + buf.WriteString("keywords:\n") + buf.WriteString(" - temporal cli\n") + buf.WriteString(" - command reference\n") + buf.WriteString("tags:\n") + buf.WriteString(" - Temporal CLI\n") + buf.WriteString("---\n\n") + buf.WriteString("This section includes the complete command reference for the `temporal` CLI, including the cloud extension.\n\n") + + // Collect and sort file names + var fileNames []string + for name := range w.fileMap { + fileNames = append(fileNames, name) + } + sort.Strings(fileNames) + + for _, name := range fileNames { + // Use the last path segment as the display name + parts := strings.Split(name, "/") + displayName := parts[len(parts)-1] + if len(parts) > 1 { + // Split command (e.g., "cloud/namespace") — show as "cloud namespace" + displayName = strings.Join(parts, " ") + } + buf.WriteString(fmt.Sprintf("- [%s](/cli/command-reference/%s)\n", displayName, name)) + } + + w.fileMap["index"] = buf +} + func (w *docWriter) processOptions(c *Command) { // Pop options from stack if we are moving up a level if len(w.optionsStack) >= len(strings.Split(c.FullName, " ")) { From c2af98ffff5ebc16c6e6573ac8da617b8e0859e2 Mon Sep 17 00:00:00 2001 From: Lenny Chen Date: Tue, 14 Apr 2026 14:52:41 -0700 Subject: [PATCH 4/4] feat: generate index page for split command subdirectories Each split parent (e.g., cloud) now gets its own index.mdx listing its subcommand pages. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/commandsgen/docs.go | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/internal/commandsgen/docs.go b/internal/commandsgen/docs.go index 5f98b4290..55c05b0de 100644 --- a/internal/commandsgen/docs.go +++ b/internal/commandsgen/docs.go @@ -423,6 +423,43 @@ func (w *docWriter) writeIndex(splitParents map[string]bool) { } w.fileMap["index"] = buf + + // Generate index pages for each split parent (e.g., cloud/index) + for parent := range splitParents { + w.writeSplitIndex(parent, fileNames) + } +} + +func (w *docWriter) writeSplitIndex(parent string, allFileNames []string) { + dirName := strings.ReplaceAll(parent, " ", "-") + fileName := dirName + "/index" + + buf := &bytes.Buffer{} + buf.WriteString("---\n") + buf.WriteString("id: index\n") + buf.WriteString("title: Temporal CLI " + parent + " command reference\n") + buf.WriteString("sidebar_label: Overview\n") + buf.WriteString("description: Command reference for the temporal " + parent + " extension.\n") + buf.WriteString("slug: /cli/command-reference/" + dirName + "\n") + buf.WriteString("toc_max_heading_level: 4\n") + buf.WriteString("keywords:\n") + buf.WriteString(" - temporal cli\n") + buf.WriteString(" - " + parent + "\n") + buf.WriteString(" - command reference\n") + buf.WriteString("tags:\n") + buf.WriteString(" - Temporal CLI\n") + buf.WriteString("---\n\n") + buf.WriteString(fmt.Sprintf("This section includes the command reference for the `temporal %s` CLI extension.\n\n", parent)) + + for _, name := range allFileNames { + if strings.HasPrefix(name, dirName+"/") { + parts := strings.Split(name, "/") + displayName := parts[len(parts)-1] + buf.WriteString(fmt.Sprintf("- [%s](/cli/command-reference/%s)\n", displayName, name)) + } + } + + w.fileMap[fileName] = buf } func (w *docWriter) processOptions(c *Command) {