Skip to content
12 changes: 10 additions & 2 deletions cmd/gen-docs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -32,10 +32,12 @@ func run() error {
var (
outputDir string
inputFiles stringSlice
subdirNames stringSlice
)

flag.Var(&inputFiles, "input", "Input YAML file (can be specified multiple times)")
flag.StringVar(&outputDir, "output", ".", "Output directory for docs")
flag.Var(&subdirNames, "subdir", "Place subcommands of this command into a subdirectory instead of a single file (can be specified multiple times)")
flag.Parse()

if len(inputFiles) == 0 {
Expand All @@ -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, subdirNames)
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)
}
Expand Down
251 changes: 245 additions & 6 deletions internal/commandsgen/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,27 @@ import (
"strings"
)

func GenerateDocsFiles(commands Commands) (map[string][]byte, error) {
// GenerateDocsFiles generates documentation files from parsed commands.
// subdirNames 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, subdirNames []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 subdirNames {
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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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{}
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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, " ")) {
Expand Down Expand Up @@ -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
}
Loading