diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 11ea430e0..f6825f328 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -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 { @@ -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 { @@ -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) } diff --git a/go.mod b/go.mod index b7e909abc..403b1a09f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/temporalio/cli -go 1.26.3 +go 1.26.4 require ( github.com/BurntSushi/toml v1.4.0 @@ -20,7 +20,7 @@ require ( go.temporal.io/api v1.62.8 go.temporal.io/sdk v1.41.1 go.temporal.io/sdk/contrib/envconfig v1.0.0 - go.temporal.io/server v1.31.0 + go.temporal.io/server v1.31.2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 golang.org/x/mod v0.35.0 golang.org/x/term v0.43.0 @@ -52,7 +52,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect - github.com/apache/thrift v0.21.0 // indirect + github.com/apache/thrift v0.23.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect @@ -190,11 +190,11 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.256.0 // indirect diff --git a/go.sum b/go.sum index 646682280..1d71eeae3 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3 github.com/alitto/pond v1.9.2 h1:9Qb75z/scEZVCoSU+osVmQ0I0JOeLfdTDafrbcJ8CLs= github.com/alitto/pond v1.9.2/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= -github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= -github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= +github.com/apache/thrift v0.23.0 h1:wKR6YnefQSEnxpEfmgTPuJibNG4bF0p2TK34tHLWi3s= +github.com/apache/thrift v0.23.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= @@ -460,8 +460,8 @@ go.temporal.io/sdk v1.41.1 h1:yOpvsHyDD1lNuwlGBv/SUodCPhjv9nDeC9lLHW/fJUA= go.temporal.io/sdk v1.41.1/go.mod h1:/InXQT5guZ6AizYzpmzr5avQ/GMgq1ZObcKlKE2AhTc= go.temporal.io/sdk/contrib/envconfig v1.0.0 h1:1Q/swVgB4EW/p3k7rI9/4hpU4/DC57FSRbU90+UisXw= go.temporal.io/sdk/contrib/envconfig v1.0.0/go.mod h1:Pj4N1lwUEvxap6quBm8GrVMSUMJhSZkVtxjt3AYnPPg= -go.temporal.io/server v1.31.0 h1:FKLodreaMXUxYc3zr6xxwxtpGz1WH/t7O0IWxV1d1x0= -go.temporal.io/server v1.31.0/go.mod h1:MTQAw8uMU3ooSHyg/62JsNu/j8lK34SfKMTXkexYcw8= +go.temporal.io/server v1.31.2 h1:0O4zQ0NGRit2Z32s1XUHfs9k8zSKqff2n1QvFttYN0M= +go.temporal.io/server v1.31.2/go.mod h1:kOOpZs6WMcLuVmlu0uH+dVD2Ul1d4hGjmGpHjxfz2EI= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -492,8 +492,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -526,8 +526,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -556,8 +556,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/internal/commandsgen/docs.go b/internal/commandsgen/docs.go index b3857503e..d699c23f6 100644 --- a/internal/commandsgen/docs.go +++ b/internal/commandsgen/docs.go @@ -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) @@ -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, " ") + 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 { @@ -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. ") @@ -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)] +} + +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) @@ -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 +// , , or . 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 , 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), "```") { + 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") } diff --git a/internal/commandsgen/docs_test.go b/internal/commandsgen/docs_test.go new file mode 100644 index 000000000..8e9391b79 --- /dev/null +++ b/internal/commandsgen/docs_test.go @@ -0,0 +1,184 @@ +package commandsgen + +import ( + "strings" + "testing" +) + +func TestEscapeMDXDescription(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "bare angle-bracket placeholder is escaped", + in: "Pass the cert via --ca-certificate to authenticate.", + want: `Pass the cert via --ca-certificate \ to authenticate.`, + }, + { + name: "placeholder with separators and colon is escaped", + in: "Set --limit and --reason .", + want: `Set --limit \ and --reason \.`, + }, + { + name: "angle brackets inside a fenced code block are left untouched", + in: "Example:\n\n```\ntemporal x --cert \n```\n", + want: "Example:\n\n```\ntemporal x --cert \n```\n", + }, + { + name: "angle brackets inside an inline code span are left untouched", + in: "Use `--cert ` here but escape there.", + want: "Use `--cert ` here but escape \\ there.", + }, + { + name: "custom heading id is converted to MDX comment form", + in: "### Resetting activities that heartbeat {#reset-heartbeats}\n\nSee [details](#reset-heartbeats).", + want: "### Resetting activities that heartbeat {/* #reset-heartbeats */}\n\nSee [details](#reset-heartbeats).", + }, + { + name: "non-heading line with {#id}-like text is not treated as a heading", + in: "See [details](#reset-heartbeats).", + want: "See [details](#reset-heartbeats).", + }, + { + name: "bare single-quoted JSON has its braces escaped", + in: `Provide default '{"some-key": "some-value"}' inline.`, + want: `Provide default '\{"some-key": "some-value"\}' inline.`, + }, + { + name: "single-quoted JSON inside a fence is left untouched", + in: "```\ntemporal x --input '{\"some-key\": \"some-value\"}'\n```", + want: "```\ntemporal x --input '{\"some-key\": \"some-value\"}'\n```", + }, + { + name: "plain prose is unchanged", + in: "This command does something useful with no special characters.", + want: "This command does something useful with no special characters.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := escapeMDXDescription(tc.in); got != tc.want { + t.Errorf("escapeMDXDescription()\n got: %q\nwant: %q", got, tc.want) + } + }) + } +} + +func TestEncodeJSONExample(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "key=json example is wrapped", + in: `Example: 'YourKey={"your": "value"}'.`, + want: "Example: `'YourKey={\"your\": \"value\"}'`.", + }, + { + name: "standalone json example is wrapped", + in: `Example: '{"some-key": "some-value"}'.`, + want: "Example: `'{\"some-key\": \"some-value\"}'`.", + }, + { + name: "nested json example is wrapped", + in: `Example: '{"a": {"b": "c"}}'.`, + want: "Example: `'{\"a\": {\"b\": \"c\"}}'`.", + }, + { + name: "no json is unchanged", + in: "A plain description without JSON.", + want: "A plain description without JSON.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := encodeJSONExample(tc.in); got != tc.want { + t.Errorf("encodeJSONExample()\n got: %q\nwant: %q", got, tc.want) + } + }) + } +} + +// splitFixture is a minimal command tree with a "cloud" root, mirroring how the +// cloud CLI extension is structured, used to exercise -subdir splitting. +const splitFixture = ` +commands: + - name: cloud + summary: Cloud + description: Manage Temporal Cloud. + - name: cloud thing + summary: Thing + description: | + Manage things. + docs: + keywords: + - thing + description-header: Manage things + tags: + - Cloud + - name: cloud thing sub + summary: Sub + description: | + Do a thing with a bare outside code. + options: + - name: flag + type: string + description: A flag. +` + +func generateSplitFixture(t *testing.T, subdirs []string) map[string][]byte { + t.Helper() + cmds, err := ParseCommands([]byte(splitFixture)) + if err != nil { + t.Fatalf("ParseCommands: %v", err) + } + docs, err := GenerateDocsFiles(cmds, subdirs) + if err != nil { + t.Fatalf("GenerateDocsFiles: %v", err) + } + return docs +} + +func TestGenerateDocsFilesSubdir(t *testing.T) { + docs := generateSplitFixture(t, []string{"cloud"}) + + if _, ok := docs["cloud/thing"]; !ok { + t.Errorf("expected split file cloud/thing, got keys: %v", keys(docs)) + } + if _, ok := docs["thing"]; ok { + t.Errorf("did not expect a flat 'thing' file when splitting cloud") + } + if _, ok := docs["cloud"]; ok { + t.Errorf("split parent 'cloud' should not produce a standalone file") + } + // Index pages are hand-maintained on the docs site, so gen-docs never emits them. + if _, ok := docs["cloud/index"]; ok { + t.Errorf("gen-docs should not emit a cloud/index page") + } + if _, ok := docs["index"]; ok { + t.Errorf("gen-docs should not emit a top-level index page") + } + + // The deeper subcommand's description is appended to its parent file and + // must be MDX-escaped there. + thing := string(docs["cloud/thing"]) + if !strings.Contains(thing, `\`) { + t.Errorf("expected escaped placeholder in cloud/thing, got:\n%s", thing) + } + if !strings.Contains(thing, "## sub") { + t.Errorf("expected 'sub' heading in cloud/thing, got:\n%s", thing) + } +} + +func keys(m map[string][]byte) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/temporalcli/commands.extension.go b/internal/temporalcli/commands.extension.go index 794242263..b00900810 100644 --- a/internal/temporalcli/commands.extension.go +++ b/internal/temporalcli/commands.extension.go @@ -25,6 +25,14 @@ var cliArgsToParseForExtension = map[string]bool{ "command-timeout": true, } +type ExtensionNonZeroExit struct { + *exec.ExitError +} + +func (err ExtensionNonZeroExit) Unwrap() error { + return err.ExitError +} + // tryExecuteExtension tries to execute an extension command if the command is not a built-in command. // It returns an error if the extension command fails, and a boolean indicating whether an extension was executed. func tryExecuteExtension(cctx *CommandContext, tcmd *TemporalCommand) (error, bool) { @@ -77,8 +85,8 @@ func tryExecuteExtension(cctx *CommandContext, tcmd *TemporalCommand) (error, bo if ctx.Err() != nil { return fmt.Errorf("program interrupted"), true } - if _, ok := err.(*exec.ExitError); ok { - return nil, true + if exitError, ok := err.(*exec.ExitError); ok { + return ExtensionNonZeroExit{exitError}, true } return fmt.Errorf("extension %s failed: %w", extPath, err), true } diff --git a/internal/temporalcli/commands.extension_test.go b/internal/temporalcli/commands.extension_test.go index 266a8fcd1..2590be0cc 100644 --- a/internal/temporalcli/commands.extension_test.go +++ b/internal/temporalcli/commands.extension_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/temporalio/cli/internal/temporalcli" "golang.org/x/tools/imports" ) @@ -232,7 +233,7 @@ func TestExtension_FailsOnNonExecutableCommand(t *testing.T) { h := newExtensionHarness(t) // Create file without execute permission. path := filepath.Join(h.binDir, "temporal-foo") - err := os.WriteFile(path, []byte("a text file"), 0644) + err := os.WriteFile(path, []byte("a text file"), 0o644) require.NoError(t, err) res := h.Execute("foo") @@ -248,7 +249,10 @@ func TestExtension_PassesThroughNonZeroExit(t *testing.T) { res := h.Execute("foo") assert.Equal(t, "Args: temporal-foo \n", res.Stdout.String()) - assert.NoError(t, res.Err) + var exitError temporalcli.ExtensionNonZeroExit + if assert.ErrorAs(t, res.Err, &exitError) { + assert.Equal(t, 42, exitError.ExitCode()) + } } func TestExtension_FailsOnCommandTimeout(t *testing.T) { @@ -306,7 +310,7 @@ func (h *extensionHarness) createExtension(name string, code ...string) string { // Write source file. srcPath := filepath.Join(h.binDir, name+".go") - require.NoError(h.t, os.WriteFile(srcPath, formatted, 0644)) + require.NoError(h.t, os.WriteFile(srcPath, formatted, 0o644)) // Build executable. binPath := filepath.Join(h.binDir, name) diff --git a/internal/temporalcli/commands.go b/internal/temporalcli/commands.go index ecb52515d..eaf08dbdf 100644 --- a/internal/temporalcli/commands.go +++ b/internal/temporalcli/commands.go @@ -145,6 +145,11 @@ func (c *CommandContext) preprocessOptions() error { if c.Err() != nil { err = fmt.Errorf("program interrupted") } + if exitError, ok := errors.AsType[ExtensionNonZeroExit](err); ok { + // An extension failed after being found and successfully started. Here we defer + // to its own error handling logic, and just copy the exit code through. + os.Exit(exitError.ExitCode()) + } fmt.Fprintf(c.Options.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -524,7 +529,7 @@ var buildInfo string func VersionString() string { // To add build-time information to the version string, use // go build -ldflags "-X github.com/temporalio/cli/internal.buildInfo=" - var bi = buildInfo + bi := buildInfo if bi != "" { bi = fmt.Sprintf(", %s", bi) }