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..55c05b0de 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) @@ -31,6 +41,9 @@ func GenerateDocsFiles(commands Commands) (map[string][]byte, error) { // 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 { @@ -45,13 +58,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 +92,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 +135,122 @@ 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") + + 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) { + 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()) @@ -224,6 +387,81 @@ 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 + + // 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) { // Pop options from stack if we are moving up a level if len(w.optionsStack) >= len(strings.Split(c.FullName, " ")) { @@ -255,10 +493,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 }