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/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 +}