Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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 {
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, 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)
}
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.
// 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)
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