Skip to content

Commit 9636869

Browse files
authored
feat: add --mintlify flag to kosli docs command (#663)
* feat: add --mintlify flag to kosli docs command Introduce internal/docgen package with a Formatter interface and two implementations (HugoFormatter, MintlifyFormatter) so the CLI can directly generate Mintlify-compatible MDX documentation. - Extract doc rendering logic from cmd/kosli/docs.go into docgen - Add MintlifyFormatter with <Warning>, <Tabs>/<Tab>, <AccordionGroup>/<Accordion> components, JSX escaping, and relative URLs - Move CommandsInTable and helpers to docgen/helpers.go - Add GenMarkdownTree tree walker using Formatter + callbacks - Wire --mintlify flag in docsOptions to select formatter - Add golden file tests for both Mintlify snyk and artifact pages - Existing Hugo golden file test unchanged (no regression) * refactor: move Hugo golden files to hugo/ subdir, add Mintlify tests Move Hugo golden files from testdata/output/docs/ to testdata/output/docs/hugo/ for symmetry with mintlify/. Hugo golden file content is unchanged. Add Mintlify golden file comparison tests for snyk and deprecated artifact commands. Update Hugo test golden path only. * fix: omit h1 title from Mintlify output Mintlify renders the front matter title as h1 automatically, so the generated docs should not include a duplicate # heading. Add Title() method to Formatter interface — Hugo emits # name, Mintlify returns empty string. Pages now start with ## Synopsis. * fix: convert docs.kosli.com URLs to clickable markdown links Replace bare https://docs.kosli.com/path URLs in Mintlify output with markdown links [docs](/path) so they are clickable both locally (mint dev) and when deployed. Applies to flag descriptions and synopsis text. * chore: minor formatting
1 parent 6589e8b commit 9636869

17 files changed

Lines changed: 1616 additions & 339 deletions

cmd/kosli/docs.go

Lines changed: 39 additions & 248 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
package main
22

33
import (
4-
"bytes"
54
"encoding/json"
65
"fmt"
76
"io"
87
"net/http"
9-
"net/url"
10-
"os"
11-
"path"
12-
"path/filepath"
138
"strings"
14-
"unicode"
159

10+
"github.com/kosli-dev/cli/internal/docgen"
1611
"github.com/spf13/cobra"
1712
"github.com/spf13/cobra/doc"
1813
)
@@ -27,6 +22,7 @@ type docsOptions struct {
2722
dest string
2823
topCmd *cobra.Command
2924
generateHeaders bool
25+
mintlify bool
3026
}
3127

3228
func newDocsCmd(out io.Writer) *cobra.Command {
@@ -47,273 +43,76 @@ func newDocsCmd(out io.Writer) *cobra.Command {
4743
f := cmd.Flags()
4844
f.StringVar(&o.dest, "dir", "./", "The directory to which documentation is written.")
4945
f.BoolVar(&o.generateHeaders, "generate-headers", true, "Generate standard headers for markdown files.")
46+
f.BoolVar(&o.mintlify, "mintlify", false, "Generate Mintlify-compatible MDX output instead of Hugo.")
5047

5148
return cmd
5249
}
5350

5451
func (o *docsOptions) run() error {
5552
if o.generateHeaders {
56-
linkHandler := func(name string) string {
57-
base := strings.TrimSuffix(name, path.Ext(name))
58-
return "/client_reference/" + strings.ToLower(base) + "/"
59-
}
60-
61-
hdrFunc := func(filename string, beta, deprecated bool, summary string) string {
62-
base := filepath.Base(filename)
63-
name := strings.TrimSuffix(base, path.Ext(base))
64-
title := strings.ToLower(strings.ReplaceAll(name, "_", " "))
65-
return fmt.Sprintf("---\ntitle: \"%s\"\nbeta: %t\ndeprecated: %t\nsummary: \"%s\"\n---\n\n", title, beta, deprecated, summary)
66-
}
67-
68-
return MereklyGenMarkdownTreeCustom(o.topCmd, o.dest, hdrFunc, linkHandler)
69-
}
70-
return doc.GenMarkdownTree(o.topCmd, o.dest)
71-
}
72-
73-
func MereklyGenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender func(string, bool, bool, string) string, linkHandler func(string) string) error {
74-
for _, c := range cmd.Commands() {
75-
// skip all unavailable commands except deprecated ones
76-
if (!c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand()) && c.Deprecated == "" {
77-
continue
78-
}
79-
if err := MereklyGenMarkdownTreeCustom(c, dir, filePrepender, linkHandler); err != nil {
80-
return err
81-
}
82-
}
83-
84-
if !cmd.HasParent() || !cmd.HasSubCommands() {
85-
basename := strings.ReplaceAll(cmd.CommandPath(), " ", "_") + ".md"
86-
filename := filepath.Join(dir, basename)
87-
summary := cmd.Short
88-
f, err := os.Create(filename)
89-
if err != nil {
90-
return err
91-
}
92-
defer func() {
93-
if err := f.Close(); err != nil {
94-
logger.Warn("failed to close file %s: %v", filename, err)
95-
}
96-
}()
97-
98-
if _, err := io.WriteString(f, filePrepender(filename, isBeta(cmd), isDeprecated(cmd), summary)); err != nil {
99-
return err
100-
}
101-
if err := KosliGenMarkdownCustom(cmd, f, linkHandler); err != nil {
102-
return err
103-
}
104-
}
105-
return nil
106-
}
107-
108-
// KosliGenMarkdownCustom creates custom markdown output.
109-
func KosliGenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) string) error {
110-
cmd.InitDefaultHelpCmd()
111-
cmd.InitDefaultHelpFlag()
112-
113-
buf := new(bytes.Buffer)
114-
name := cmd.CommandPath()
115-
116-
buf.WriteString("# " + name + "\n\n")
117-
118-
if isBeta(cmd) {
119-
buf.WriteString("{{% hint warning %}}\n")
120-
fmt.Fprintf(buf, "**%s** is a beta feature. ", name)
121-
fmt.Fprintf(buf, "Beta features provide early access to product functionality. ")
122-
fmt.Fprintf(buf, "These features may change between releases without warning, or can be removed in a ")
123-
fmt.Fprintf(buf, "future release.\n")
124-
fmt.Fprintf(buf, "Please contact us to enable this feature for your organization.\n")
125-
// fmt.Fprintf(buf, "You can enable beta features by using the `kosli enable beta` command.")
126-
buf.WriteString("{{% /hint %}}\n")
127-
}
128-
129-
if isDeprecated(cmd) {
130-
buf.WriteString("{{% hint danger %}}\n")
131-
fmt.Fprintf(buf, "**%s** is deprecated. %s ", name, cmd.Deprecated)
132-
fmt.Fprintf(buf, "Deprecated commands will be removed in a future release.\n")
133-
buf.WriteString("{{% /hint %}}\n")
134-
}
135-
136-
if len(cmd.Long) > 0 {
137-
buf.WriteString("## Synopsis\n\n")
138-
if cmd.Runnable() {
139-
fmt.Fprintf(buf, "```shell\n%s\n```\n\n", cmd.UseLine())
140-
}
141-
buf.WriteString(strings.ReplaceAll(cmd.Long, "^", "`") + "\n\n")
142-
}
143-
144-
if err := printOptions(buf, cmd, name); err != nil {
145-
return err
146-
}
147-
148-
urlSafeName := url.QueryEscape(name)
149-
liveExamplesBuf := new(bytes.Buffer)
150-
for _, ci := range []string{"GitHub", "GitLab"} {
151-
if liveYamlDocExists(ci, urlSafeName) {
152-
fmt.Fprintf(liveExamplesBuf, "{{< tab \"%v\" >}}", ci)
153-
fmt.Fprintf(liveExamplesBuf, "View an example of the `%s` command in %s.\n\n", name, ci)
154-
fmt.Fprintf(liveExamplesBuf, "In [this YAML file](%v)", yamlURL(ci, urlSafeName))
155-
if liveEventDocExists(ci, urlSafeName) {
156-
fmt.Fprintf(liveExamplesBuf, ", which created [this Kosli Event](%v).", eventURL(ci, urlSafeName))
157-
}
158-
liveExamplesBuf.WriteString("{{< /tab >}}")
159-
}
160-
}
161-
liveExamples := liveExamplesBuf.String()
162-
if len(liveExamples) > 0 {
163-
buf.WriteString("## Live Examples in different CI systems\n\n")
164-
buf.WriteString("{{< tabs \"live-examples\" \"col-no-wrap\" >}}")
165-
buf.WriteString(liveExamples)
166-
buf.WriteString("{{< /tabs >}}\n\n")
167-
}
168-
169-
liveCliFullCommand, liveCliURL, liveCliExists := liveCliDocExists(name)
170-
if liveCliExists {
171-
buf.WriteString("## Live Example\n\n")
172-
buf.WriteString("{{< raw-html >}}")
173-
fmt.Fprintf(buf, "To view a live example of '%s' you can run the commands below (for the <a href=\"https://app.kosli.com/cyber-dojo/environments/aws-prod/snapshots/\">cyber-dojo</a> demo organization).<br/><a href=\"%s\">Run the commands below and view the output.</a>", name, liveCliURL)
174-
buf.WriteString("<pre>")
175-
buf.WriteString("export KOSLI_ORG=cyber-dojo\n")
176-
buf.WriteString("export KOSLI_API_TOKEN=Pj_XT2deaVA6V1qrTlthuaWsmjVt4eaHQwqnwqjRO3A # read-only\n")
177-
buf.WriteString(liveCliFullCommand)
178-
buf.WriteString("</pre>")
179-
buf.WriteString("{{< / raw-html >}}\n\n")
180-
}
181-
182-
if len(cmd.Example) > 0 {
183-
// This is an attempt to tidy up the non-live examples, so they each have their own title.
184-
// Note: The contents of the title lines could also contain < and > characters which will
185-
// be lost if simply embedded in a md ## section.
186-
buf.WriteString("## Examples Use Cases\n\n")
187-
url := "https://docs.kosli.com/getting_started/install/#assigning-flags-via-environment-variables"
188-
message := fmt.Sprintf("These examples all assume that the flags `--api-token`, `--org`, `--host`, (and `--flow`, `--trail` when required), are [set/provided](%v). \n\n", url)
189-
buf.WriteString(message)
190-
191-
// Some non-title lines contain a # character, (eg in a snappish) so we have to
192-
// split on newlines first and then only split on # in the first position
193-
example := strings.TrimSpace(cmd.Example)
194-
lines := strings.Split(example, "\n")
195-
196-
// Some commands have #titles spanning several lines (that is, each title line starts with a # character)
197-
if name == "kosli report approval" {
198-
fmt.Fprintf(buf, "```shell\n%s\n```\n\n", example)
199-
} else if name == "kosli request approval" {
200-
fmt.Fprintf(buf, "```shell\n%s\n```\n\n", example)
201-
} else if name == "kosli snapshot server" {
202-
fmt.Fprintf(buf, "```shell\n%s\n```\n\n", example)
203-
} else if lines[0][0] != '#' {
204-
// Some commands, eg 'kosli assert snapshot' have no #title
205-
// and their example starts immediately with the kosli command.
206-
fmt.Fprintf(buf, "```shell\n%s\n```\n\n", example)
53+
var formatter docgen.Formatter
54+
if o.mintlify {
55+
formatter = docgen.MintlifyFormatter{}
20756
} else {
208-
// The rest we can format nicely
209-
all := hashTitledExamples(lines)
210-
for i := 0; i < len(all); i++ {
211-
exampleLines := all[i]
212-
// Some titles have a trailing colon, some don't
213-
title := strings.Trim(exampleLines[0], ":")
214-
if len(title) > 0 {
215-
fmt.Fprintf(buf, "##### %s\n\n", strings.TrimSpace(title[1:]))
216-
fmt.Fprintf(buf, "```shell\n%s\n```\n\n", strings.Join(exampleLines[1:], "\n"))
217-
}
218-
}
57+
formatter = docgen.HugoFormatter{}
21958
}
220-
}
22159

222-
_, err := buf.WriteTo(w)
223-
return err
224-
}
225-
226-
func hashTitledExamples(lines []string) [][]string {
227-
// Some non-title lines contain a # character, so we have split on newlines first
228-
// and then split on # which are the first character in their line
229-
result := make([][]string, 0)
230-
example := make([]string, 0)
231-
for _, line := range lines {
232-
if strings.HasPrefix(line, "#") {
233-
result = append(result, example) // See result[1:] at end
234-
example = make([]string, 0)
235-
}
236-
if !isSetWithEnvVar(line) {
237-
example = append(example, choppedLineContinuation(line))
60+
metaFn := func(cmd *cobra.Command) docgen.CommandMeta {
61+
return docgen.CommandMeta{
62+
Name: cmd.CommandPath(),
63+
Beta: isBeta(cmd),
64+
Deprecated: isDeprecated(cmd),
65+
DeprecMsg: cmd.Deprecated,
66+
Summary: cmd.Short,
67+
Long: cmd.Long,
68+
UseLine: cmd.UseLine(),
69+
Runnable: cmd.Runnable(),
70+
Example: cmd.Example,
71+
}
23872
}
239-
}
240-
result = append(result, example)
241-
return result[1:]
242-
}
24373

244-
func isSetWithEnvVar(line string) bool {
245-
trimmed_line := strings.TrimSpace(line)
246-
if strings.HasPrefix(trimmed_line, "--api-token ") {
247-
return true
248-
} else if strings.HasPrefix(trimmed_line, "--host ") {
249-
return true
250-
} else if strings.HasPrefix(trimmed_line, "--org ") {
251-
return true
252-
} else if strings.HasPrefix(trimmed_line, "--flow ") {
253-
return true
254-
} else if strings.HasPrefix(trimmed_line, "--trail ") {
255-
return true
256-
} else {
257-
return false
74+
return docgen.GenMarkdownTree(o.topCmd, o.dest, formatter, metaFn, &kosliLiveDocProvider{})
25875
}
76+
return doc.GenMarkdownTree(o.topCmd, o.dest)
25977
}
26078

261-
func choppedLineContinuation(line string) string {
262-
trimmed_line := strings.TrimRightFunc(line, unicode.IsSpace)
263-
return strings.TrimSuffix(trimmed_line, "\\")
264-
}
265-
266-
func printOptions(buf *bytes.Buffer, cmd *cobra.Command, name string) error {
267-
flags := cmd.NonInheritedFlags()
268-
flags.SetOutput(buf)
269-
if flags.HasAvailableFlags() {
270-
buf.WriteString("## Flags\n")
271-
buf.WriteString("| Flag | Description |\n")
272-
buf.WriteString("| :--- | :--- |\n")
273-
usages := CommandsInTable(flags)
274-
fmt.Fprint(buf, usages)
275-
buf.WriteString("\n\n")
276-
}
277-
278-
parentFlags := cmd.InheritedFlags()
279-
parentFlags.SetOutput(buf)
280-
if parentFlags.HasAvailableFlags() {
281-
buf.WriteString("## Flags inherited from parent commands\n")
282-
buf.WriteString("| Flag | Description |\n")
283-
buf.WriteString("| :--- | :--- |\n")
284-
usages := CommandsInTable(parentFlags)
285-
fmt.Fprint(buf, usages)
286-
buf.WriteString("\n\n")
287-
}
288-
return nil
289-
}
79+
// kosliLiveDocProvider implements docgen.LiveDocProvider using HTTP calls
80+
// to the Kosli live docs API.
81+
type kosliLiveDocProvider struct{}
29082

29183
const baseURL = "https://app.kosli.com/api/v2/livedocs/cyber-dojo"
29284

293-
func liveYamlDocExists(ci string, command string) bool {
85+
func (p *kosliLiveDocProvider) YamlDocExists(ci, command string) bool {
29486
url := fmt.Sprintf("%s/yaml_exists?ci=%s&command=%s", baseURL, strings.ToLower(ci), command)
29587
return liveDocExists(url)
29688
}
29789

298-
func liveEventDocExists(ci string, command string) bool {
90+
func (p *kosliLiveDocProvider) EventDocExists(ci, command string) bool {
29991
url := fmt.Sprintf("%s/event_exists?ci=%s&command=%s", baseURL, strings.ToLower(ci), command)
30092
return liveDocExists(url)
30193
}
30294

303-
func liveCliDocExists(command string) (string, string, bool) {
95+
func (p *kosliLiveDocProvider) YamlURL(ci, command string) string {
96+
return fmt.Sprintf("%s/yaml?ci=%s&command=%s", baseURL, strings.ToLower(ci), command)
97+
}
98+
99+
func (p *kosliLiveDocProvider) EventURL(ci, command string) string {
100+
return fmt.Sprintf("%s/event?ci=%s&command=%s", baseURL, strings.ToLower(ci), command)
101+
}
102+
103+
func (p *kosliLiveDocProvider) CLIDocExists(command string) (string, string, bool) {
304104
fullCommand, ok := liveCliMap[command]
305105
if ok {
306106
plussed := strings.ReplaceAll(fullCommand, " ", "+")
307-
exists_url := fmt.Sprintf("%s/cli_exists?command=%s", baseURL, plussed)
107+
existsURL := fmt.Sprintf("%s/cli_exists?command=%s", baseURL, plussed)
308108
url := fmt.Sprintf("%s/cli?command=%s", baseURL, plussed)
309-
return fullCommand, url, liveDocExists(exists_url)
310-
} else {
311-
return "", "", false
109+
return fullCommand, url, liveDocExists(existsURL)
312110
}
111+
return "", "", false
313112
}
314113

315114
func liveDocExists(url string) bool {
316-
response, err := http.Get(url)
115+
response, err := http.Get(url) //nolint:gosec
317116
if err != nil {
318117
return false
319118
}
@@ -331,14 +130,6 @@ func liveDocExists(url string) bool {
331130
return exists
332131
}
333132

334-
func yamlURL(ci string, command string) string {
335-
return fmt.Sprintf("%s/yaml?ci=%s&command=%s", baseURL, strings.ToLower(ci), command)
336-
}
337-
338-
func eventURL(ci string, command string) string {
339-
return fmt.Sprintf("%s/event?ci=%s&command=%s", baseURL, strings.ToLower(ci), command)
340-
}
341-
342133
var liveCliMap = map[string]string{
343134
"kosli list environments": "kosli list environments --output=json",
344135
"kosli get environment": "kosli get environment aws-prod --output=json",

0 commit comments

Comments
 (0)