Skip to content
Merged
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,8 @@ import (
"github.com/temporalio/cli/internal/commandsgen"
)

// stringSlice implements flag.Value to support multiple -input flags
// stringSlice implements flag.Value to support flags that may be specified
// multiple times (e.g. -input, -subdir).
type stringSlice []string

func (s *stringSlice) String() string {
Expand All @@ -32,10 +33,12 @@ func run() error {
var (
outputDir string
inputFiles stringSlice
subdirs stringSlice
)

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

if len(inputFiles) == 0 {
Expand All @@ -60,13 +63,18 @@ func run() error {
return fmt.Errorf("failed parsing YAML: %w", err)
}

docs, err := commandsgen.GenerateDocsFiles(cmds)
docs, err := commandsgen.GenerateDocsFiles(cmds, subdirs)
if err != nil {
return fmt.Errorf("failed generating docs: %w", err)
}

for filename, content := range docs {
filePath := filepath.Join(outputDir, filename+".mdx")
// Filenames may contain a path separator (e.g. "cloud/namespace") when
// -subdir is used, so ensure the parent directory exists.
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return fmt.Errorf("failed creating directory for %s: %w", filePath, err)
}
if err := os.WriteFile(filePath, content, 0644); err != nil {
return fmt.Errorf("failed writing %s: %w", filePath, err)
}
Expand Down
273 changes: 237 additions & 36 deletions internal/commandsgen/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,33 @@ import (
"strings"
)

func GenerateDocsFiles(commands Commands) (map[string][]byte, error) {
// GenerateDocsFiles generates the MDX documentation files from parsed commands.
//
// subdirNames lists command names whose subcommands should each be written to
// their own file within a subdirectory instead of being combined into a single
// file. For example, passing "cloud" produces cloud/namespace.mdx, cloud/user.mdx,
// etc. rather than a single, unmanageably large cloud.mdx.
//
// Index/landing pages are intentionally not generated: on the docs site they are
// hand-maintained (custom ordering, front matter, embedded components), so
// generating them would overwrite that curated content.
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 @@ -45,13 +61,33 @@ type docWriter struct {
optionSetMap map[string]OptionSets
optionsStack [][]Option
globalFlagsMap map[string]map[string]Option // fileName -> optionName -> Option
splitParents map[string]bool // command names whose subcommands are split into a subdirectory
}

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") has no standalone file; its children are
// each written into a subdirectory instead.
if w.splitParents[c.FullName] {
return nil
}
// If any ancestor is a split parent, route this command into that subdirectory.
// FullName segments are separated by single spaces (never multiple), so
// splitting on " " yields the exact command path.
if depth >= 1 {
parts := strings.Split(c.FullName, " ")
Comment thread
chaptersix marked this conversation as resolved.
for i := len(parts) - 1; i >= 1; i-- {
ancestor := strings.Join(parts[:i], " ")
if w.splitParents[ancestor] {
c.writeSplitDoc(w, ancestor)
return nil
}
}
}

if depth == 1 {
w.writeCommand(c)
} else if depth > 1 {
Expand Down Expand Up @@ -80,8 +116,7 @@ func (w *docWriter) writeCommand(c *Command) {
}
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/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */}\n\n")
w.fileMap[fileName].WriteString(autoGeneratedNotice)
// Add introductory paragraph
w.fileMap[fileName].WriteString(fmt.Sprintf("This page provides a reference for the `temporal` CLI `%s` command. ", fileName))
w.fileMap[fileName].WriteString("The flags applicable to each subcommand are presented in a table within the heading for the subcommand. ")
Expand All @@ -92,45 +127,146 @@ func (w *docWriter) writeSubcommand(c *Command) {
fileName := c.fileName()
prefix := strings.Repeat("#", c.depth())
w.fileMap[fileName].WriteString(prefix + " " + c.leafName() + "\n\n")
w.fileMap[fileName].WriteString(c.Description + "\n\n")
w.fileMap[fileName].WriteString(escapeMDXDescription(c.Description) + "\n\n")

if w.isLeafCommand(c) {
// gather options from command and all options available from parent commands
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...)
}
}

// alphabetize options
sort.Slice(options, func(i, j int) bool {
return options[i].Name < options[j].Name
})
w.writeLeafOptions(fileName)
}
}

// Write command-specific flags or global flags message
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.writeOptionsTable(options, c)
// writeLeafOptions writes the option table (or the global-flags fallback message)
// for a leaf command, and records its inherited options for the file's Global
// Flags section. The command's own options are the top frame of the options
// stack; everything below it is inherited from parent commands.
//
// The reference to the "#global-flags" anchor is only emitted when there are
// inherited options, since that is what causes a Global Flags section (and thus
// the anchor) to be generated for the file. A command with no inherited options
// (e.g. a top-level command in a split subdirectory) would otherwise link to an
// anchor that never exists.
func (w *docWriter) writeLeafOptions(fileName string) {
var options, globalOptions []Option
for i, o := range w.optionsStack {
if i == len(w.optionsStack)-1 {
options = append(options, o...)
} else {
w.fileMap[fileName].WriteString("Use [global flags](#global-flags) to customize the connection to the Temporal Service for this command.\n\n")
globalOptions = append(globalOptions, o...)
}
}

sort.Slice(options, func(i, j int) bool {
return options[i].Name < options[j].Name
})

buf := w.fileMap[fileName]
hasGlobal := len(globalOptions) > 0
switch {
case len(options) > 0 && hasGlobal:
buf.WriteString("Use the following options to change the behavior of this command. ")
buf.WriteString("You can also use any of the [global flags](#global-flags) that apply to all subcommands.\n\n")
w.writeOptionsTable(options, fileName)
case len(options) > 0:
buf.WriteString("Use the following options to change the behavior of this command.\n\n")
w.writeOptionsTable(options, fileName)
case hasGlobal:
buf.WriteString("Use [global flags](#global-flags) to customize the connection to the Temporal Service for this command.\n\n")
}

w.collectGlobalFlags(fileName, globalOptions)
}

// writeSplitDoc routes a command that lives under a split parent to the right
// writer: direct children get their own file, deeper subcommands are appended
// as headings within their parent's file.
func (c *Command) writeSplitDoc(w *docWriter, splitRoot string) {
splitDepth := len(strings.Split(splitRoot, " "))
relativeDepth := len(strings.Split(c.FullName, " ")) - splitDepth

switch {
case relativeDepth == 1:
w.writeSplitCommand(c, splitRoot)
case relativeDepth > 1:
w.writeSplitSubcommand(c, splitRoot)
}
}

// splitFileName returns the file path for a command within a split parent.
// For example, with splitRoot "cloud" and command "cloud namespace", it returns
// "cloud/namespace". A multi-word split root is hyphenated into a single
// directory: splitRoot "one two" with command "one two three" returns
// "one-two/three" (not "one/two/three").
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)]
Comment thread
chaptersix marked this conversation as resolved.
}

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)]

buf := &bytes.Buffer{}
w.fileMap[fileName] = buf
buf.WriteString("---\n")
buf.WriteString("id: " + leafName + "\n")
buf.WriteString("title: Temporal CLI " + c.FullName + " command reference\n")
buf.WriteString("sidebar_label: " + leafName + "\n")
buf.WriteString("description: " + c.Docs.DescriptionHeader + "\n")
buf.WriteString("toc_max_heading_level: 4\n")
buf.WriteString("keywords:\n")
for _, keyword := range c.Docs.Keywords {
buf.WriteString(" - " + keyword + "\n")
}
buf.WriteString("tags:\n")
for _, tag := range c.Docs.Tags {
buf.WriteString(" - " + tag + "\n")
}
buf.WriteString("---")
buf.WriteString("\n\n")
buf.WriteString(autoGeneratedNotice)

buf.WriteString(escapeMDXDescription(c.Description) + "\n\n")

if w.isLeafCommand(c) {
w.writeLeafOptions(fileName)
} else {
buf.WriteString(fmt.Sprintf("This page provides a reference for the `temporal %s` commands. ", c.FullName))
buf.WriteString("The flags applicable to each subcommand are presented in a table within the heading for the subcommand. ")
buf.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)
// Use the path relative to the split root (e.g. "ha update", "cert-ca create")
// rather than just the final segment. Within one aggregated file this keeps
// each heading's generated anchor unique and stable, which other docs link to
// (e.g. /cloud/high-availability/enable -> cloud/namespace#ha-update).
leafName := strings.Join(cmdParts[len(splitParts)+1:], " ")

buf := w.fileMap[fileName]
buf.WriteString(prefix + " " + leafName + "\n\n")
buf.WriteString(escapeMDXDescription(c.Description) + "\n\n")

// Collect global flags for later (deduplicated)
w.collectGlobalFlags(fileName, globalOptions)
if w.isLeafCommand(c) {
w.writeLeafOptions(fileName)
}
}

func (w *docWriter) writeOptionsTable(options []Option, c *Command) {
func (w *docWriter) writeOptionsTable(options []Option, fileName string) {
if len(options) == 0 {
return
}

fileName := c.fileName()
buf := w.fileMap[fileName]

// Command-specific flags: 3 columns (no Default)
Expand Down Expand Up @@ -254,11 +390,76 @@ func (w *docWriter) isLeafCommand(c *Command) bool {
return true
}

const autoGeneratedNotice = "{/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten.\n" +
"This file is generated from the CLI command definitions via cmd/gen-docs. */}\n\n"

// jsonInSingleQuotes matches a single-quoted string containing curly braces,
// e.g. 'YourKey={"a":"b"}' or '{"a":"b"}'. MDX interprets the braces as a JSX
// expression, so such examples must be wrapped in backticks (option table cells)
// or backslash-escaped (description body text).
var jsonInSingleQuotes = regexp.MustCompile(`'[^']*\{[^']*\}[^']*'`)

// angleBracketPlaceholder matches lowercase angle-bracket placeholders such as
// <base64-encoded-cert>, <key>, or <requests_per_second:float>. Restricting the
// match to lowercase, multi-character names avoids escaping legitimate inline
// HTML that authors may intentionally include.
var angleBracketPlaceholder = regexp.MustCompile(`<([a-z][a-z0-9_:.-]+)>`)

// headingID matches a Markdown heading line ending with a custom ID, e.g.
// "### Some heading {#custom-id}". The Docusaurus MDX parser (3.10+) treats the
// bare {#custom-id} as a JSX expression and fails to compile it.
var headingID = regexp.MustCompile(`^(#{1,6}\s+.+?)\s+\{#([\w-]+)\}\s*$`)

// encodeJSONExample wraps single-quoted JSON examples in backticks so MDX renders
// them as inline code rather than parsing the curly braces as a JSX expression.
// This handles option description table cells; escapeMDXDescription handles the
// same class of issue for command description body text.
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]*={.*}')`)
v = re.ReplaceAllString(v, "`$1`")
return v
return jsonInSingleQuotes.ReplaceAllString(v, "`$0`")
}

// escapeMDXDescription escapes patterns in a command description that are valid
// CommonMark but break MDX (JSX) compilation under Docusaurus 3.10+:
//
// - bare angle-bracket placeholders like <base64-encoded-cert>, which MDX
// parses as an unclosed JSX/HTML tag;
// - curly braces in single-quoted JSON examples like '{"a":"b"}', which MDX
// parses as a JSX expression; and
// - custom heading IDs like "## Heading {#id}", where MDX parses the {#id} as
// a JSX expression and fails on the '#'. These are converted to Docusaurus's
// MDX-compatible {/* #id */} comment form, which preserves the custom anchor.
//
// Content inside fenced code blocks (```) and inline code spans (backticks) is
// left untouched, since MDX does not interpret those as JSX. Fence markers are
// assumed to be on their own line (the standard CommonMark form the command
// descriptions use): a ``` opening or closing a block must start the line.
func escapeMDXDescription(desc string) string {
lines := strings.Split(desc, "\n")
inCodeBlock := false
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "```") {
Comment thread
chaptersix marked this conversation as resolved.
inCodeBlock = !inCodeBlock
continue
}
if inCodeBlock {
continue
}

line = headingID.ReplaceAllString(line, "$1 {/* #$2 */}")

// Escape only outside inline code spans. Splitting on backticks yields
// alternating outside/inside segments (assuming balanced backticks).
segments := strings.Split(line, "`")
for j := range segments {
if j%2 != 0 {
continue // inside an inline code span
}
segments[j] = angleBracketPlaceholder.ReplaceAllString(segments[j], `\<$1\>`)
segments[j] = jsonInSingleQuotes.ReplaceAllStringFunc(segments[j], func(m string) string {
return strings.NewReplacer("{", `\{`, "}", `\}`).Replace(m)
})
}
lines[i] = strings.Join(segments, "`")
}
return strings.Join(lines, "\n")
}
Loading
Loading