diff --git a/cmd/kosli/docs.go b/cmd/kosli/docs.go index c8dde1f49..a18f72346 100644 --- a/cmd/kosli/docs.go +++ b/cmd/kosli/docs.go @@ -1,18 +1,13 @@ package main import ( - "bytes" "encoding/json" "fmt" "io" "net/http" - "net/url" - "os" - "path" - "path/filepath" "strings" - "unicode" + "github.com/kosli-dev/cli/internal/docgen" "github.com/spf13/cobra" "github.com/spf13/cobra/doc" ) @@ -27,6 +22,7 @@ type docsOptions struct { dest string topCmd *cobra.Command generateHeaders bool + mintlify bool } func newDocsCmd(out io.Writer) *cobra.Command { @@ -47,273 +43,76 @@ func newDocsCmd(out io.Writer) *cobra.Command { f := cmd.Flags() f.StringVar(&o.dest, "dir", "./", "The directory to which documentation is written.") f.BoolVar(&o.generateHeaders, "generate-headers", true, "Generate standard headers for markdown files.") + f.BoolVar(&o.mintlify, "mintlify", false, "Generate Mintlify-compatible MDX output instead of Hugo.") return cmd } func (o *docsOptions) run() error { if o.generateHeaders { - linkHandler := func(name string) string { - base := strings.TrimSuffix(name, path.Ext(name)) - return "/client_reference/" + strings.ToLower(base) + "/" - } - - hdrFunc := func(filename string, beta, deprecated bool, summary string) string { - base := filepath.Base(filename) - name := strings.TrimSuffix(base, path.Ext(base)) - title := strings.ToLower(strings.ReplaceAll(name, "_", " ")) - return fmt.Sprintf("---\ntitle: \"%s\"\nbeta: %t\ndeprecated: %t\nsummary: \"%s\"\n---\n\n", title, beta, deprecated, summary) - } - - return MereklyGenMarkdownTreeCustom(o.topCmd, o.dest, hdrFunc, linkHandler) - } - return doc.GenMarkdownTree(o.topCmd, o.dest) -} - -func MereklyGenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender func(string, bool, bool, string) string, linkHandler func(string) string) error { - for _, c := range cmd.Commands() { - // skip all unavailable commands except deprecated ones - if (!c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand()) && c.Deprecated == "" { - continue - } - if err := MereklyGenMarkdownTreeCustom(c, dir, filePrepender, linkHandler); err != nil { - return err - } - } - - if !cmd.HasParent() || !cmd.HasSubCommands() { - basename := strings.ReplaceAll(cmd.CommandPath(), " ", "_") + ".md" - filename := filepath.Join(dir, basename) - summary := cmd.Short - f, err := os.Create(filename) - if err != nil { - return err - } - defer func() { - if err := f.Close(); err != nil { - logger.Warn("failed to close file %s: %v", filename, err) - } - }() - - if _, err := io.WriteString(f, filePrepender(filename, isBeta(cmd), isDeprecated(cmd), summary)); err != nil { - return err - } - if err := KosliGenMarkdownCustom(cmd, f, linkHandler); err != nil { - return err - } - } - return nil -} - -// KosliGenMarkdownCustom creates custom markdown output. -func KosliGenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) string) error { - cmd.InitDefaultHelpCmd() - cmd.InitDefaultHelpFlag() - - buf := new(bytes.Buffer) - name := cmd.CommandPath() - - buf.WriteString("# " + name + "\n\n") - - if isBeta(cmd) { - buf.WriteString("{{% hint warning %}}\n") - fmt.Fprintf(buf, "**%s** is a beta feature. ", name) - fmt.Fprintf(buf, "Beta features provide early access to product functionality. ") - fmt.Fprintf(buf, "These features may change between releases without warning, or can be removed in a ") - fmt.Fprintf(buf, "future release.\n") - fmt.Fprintf(buf, "Please contact us to enable this feature for your organization.\n") - // fmt.Fprintf(buf, "You can enable beta features by using the `kosli enable beta` command.") - buf.WriteString("{{% /hint %}}\n") - } - - if isDeprecated(cmd) { - buf.WriteString("{{% hint danger %}}\n") - fmt.Fprintf(buf, "**%s** is deprecated. %s ", name, cmd.Deprecated) - fmt.Fprintf(buf, "Deprecated commands will be removed in a future release.\n") - buf.WriteString("{{% /hint %}}\n") - } - - if len(cmd.Long) > 0 { - buf.WriteString("## Synopsis\n\n") - if cmd.Runnable() { - fmt.Fprintf(buf, "```shell\n%s\n```\n\n", cmd.UseLine()) - } - buf.WriteString(strings.ReplaceAll(cmd.Long, "^", "`") + "\n\n") - } - - if err := printOptions(buf, cmd, name); err != nil { - return err - } - - urlSafeName := url.QueryEscape(name) - liveExamplesBuf := new(bytes.Buffer) - for _, ci := range []string{"GitHub", "GitLab"} { - if liveYamlDocExists(ci, urlSafeName) { - fmt.Fprintf(liveExamplesBuf, "{{< tab \"%v\" >}}", ci) - fmt.Fprintf(liveExamplesBuf, "View an example of the `%s` command in %s.\n\n", name, ci) - fmt.Fprintf(liveExamplesBuf, "In [this YAML file](%v)", yamlURL(ci, urlSafeName)) - if liveEventDocExists(ci, urlSafeName) { - fmt.Fprintf(liveExamplesBuf, ", which created [this Kosli Event](%v).", eventURL(ci, urlSafeName)) - } - liveExamplesBuf.WriteString("{{< /tab >}}") - } - } - liveExamples := liveExamplesBuf.String() - if len(liveExamples) > 0 { - buf.WriteString("## Live Examples in different CI systems\n\n") - buf.WriteString("{{< tabs \"live-examples\" \"col-no-wrap\" >}}") - buf.WriteString(liveExamples) - buf.WriteString("{{< /tabs >}}\n\n") - } - - liveCliFullCommand, liveCliURL, liveCliExists := liveCliDocExists(name) - if liveCliExists { - buf.WriteString("## Live Example\n\n") - buf.WriteString("{{< raw-html >}}") - fmt.Fprintf(buf, "To view a live example of '%s' you can run the commands below (for the cyber-dojo demo organization).
Run the commands below and view the output.", name, liveCliURL) - buf.WriteString("
")
-		buf.WriteString("export KOSLI_ORG=cyber-dojo\n")
-		buf.WriteString("export KOSLI_API_TOKEN=Pj_XT2deaVA6V1qrTlthuaWsmjVt4eaHQwqnwqjRO3A  # read-only\n")
-		buf.WriteString(liveCliFullCommand)
-		buf.WriteString("
") - buf.WriteString("{{< / raw-html >}}\n\n") - } - - if len(cmd.Example) > 0 { - // This is an attempt to tidy up the non-live examples, so they each have their own title. - // Note: The contents of the title lines could also contain < and > characters which will - // be lost if simply embedded in a md ## section. - buf.WriteString("## Examples Use Cases\n\n") - url := "https://docs.kosli.com/getting_started/install/#assigning-flags-via-environment-variables" - 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) - buf.WriteString(message) - - // Some non-title lines contain a # character, (eg in a snappish) so we have to - // split on newlines first and then only split on # in the first position - example := strings.TrimSpace(cmd.Example) - lines := strings.Split(example, "\n") - - // Some commands have #titles spanning several lines (that is, each title line starts with a # character) - if name == "kosli report approval" { - fmt.Fprintf(buf, "```shell\n%s\n```\n\n", example) - } else if name == "kosli request approval" { - fmt.Fprintf(buf, "```shell\n%s\n```\n\n", example) - } else if name == "kosli snapshot server" { - fmt.Fprintf(buf, "```shell\n%s\n```\n\n", example) - } else if lines[0][0] != '#' { - // Some commands, eg 'kosli assert snapshot' have no #title - // and their example starts immediately with the kosli command. - fmt.Fprintf(buf, "```shell\n%s\n```\n\n", example) + var formatter docgen.Formatter + if o.mintlify { + formatter = docgen.MintlifyFormatter{} } else { - // The rest we can format nicely - all := hashTitledExamples(lines) - for i := 0; i < len(all); i++ { - exampleLines := all[i] - // Some titles have a trailing colon, some don't - title := strings.Trim(exampleLines[0], ":") - if len(title) > 0 { - fmt.Fprintf(buf, "##### %s\n\n", strings.TrimSpace(title[1:])) - fmt.Fprintf(buf, "```shell\n%s\n```\n\n", strings.Join(exampleLines[1:], "\n")) - } - } + formatter = docgen.HugoFormatter{} } - } - _, err := buf.WriteTo(w) - return err -} - -func hashTitledExamples(lines []string) [][]string { - // Some non-title lines contain a # character, so we have split on newlines first - // and then split on # which are the first character in their line - result := make([][]string, 0) - example := make([]string, 0) - for _, line := range lines { - if strings.HasPrefix(line, "#") { - result = append(result, example) // See result[1:] at end - example = make([]string, 0) - } - if !isSetWithEnvVar(line) { - example = append(example, choppedLineContinuation(line)) + metaFn := func(cmd *cobra.Command) docgen.CommandMeta { + return docgen.CommandMeta{ + Name: cmd.CommandPath(), + Beta: isBeta(cmd), + Deprecated: isDeprecated(cmd), + DeprecMsg: cmd.Deprecated, + Summary: cmd.Short, + Long: cmd.Long, + UseLine: cmd.UseLine(), + Runnable: cmd.Runnable(), + Example: cmd.Example, + } } - } - result = append(result, example) - return result[1:] -} -func isSetWithEnvVar(line string) bool { - trimmed_line := strings.TrimSpace(line) - if strings.HasPrefix(trimmed_line, "--api-token ") { - return true - } else if strings.HasPrefix(trimmed_line, "--host ") { - return true - } else if strings.HasPrefix(trimmed_line, "--org ") { - return true - } else if strings.HasPrefix(trimmed_line, "--flow ") { - return true - } else if strings.HasPrefix(trimmed_line, "--trail ") { - return true - } else { - return false + return docgen.GenMarkdownTree(o.topCmd, o.dest, formatter, metaFn, &kosliLiveDocProvider{}) } + return doc.GenMarkdownTree(o.topCmd, o.dest) } -func choppedLineContinuation(line string) string { - trimmed_line := strings.TrimRightFunc(line, unicode.IsSpace) - return strings.TrimSuffix(trimmed_line, "\\") -} - -func printOptions(buf *bytes.Buffer, cmd *cobra.Command, name string) error { - flags := cmd.NonInheritedFlags() - flags.SetOutput(buf) - if flags.HasAvailableFlags() { - buf.WriteString("## Flags\n") - buf.WriteString("| Flag | Description |\n") - buf.WriteString("| :--- | :--- |\n") - usages := CommandsInTable(flags) - fmt.Fprint(buf, usages) - buf.WriteString("\n\n") - } - - parentFlags := cmd.InheritedFlags() - parentFlags.SetOutput(buf) - if parentFlags.HasAvailableFlags() { - buf.WriteString("## Flags inherited from parent commands\n") - buf.WriteString("| Flag | Description |\n") - buf.WriteString("| :--- | :--- |\n") - usages := CommandsInTable(parentFlags) - fmt.Fprint(buf, usages) - buf.WriteString("\n\n") - } - return nil -} +// kosliLiveDocProvider implements docgen.LiveDocProvider using HTTP calls +// to the Kosli live docs API. +type kosliLiveDocProvider struct{} const baseURL = "https://app.kosli.com/api/v2/livedocs/cyber-dojo" -func liveYamlDocExists(ci string, command string) bool { +func (p *kosliLiveDocProvider) YamlDocExists(ci, command string) bool { url := fmt.Sprintf("%s/yaml_exists?ci=%s&command=%s", baseURL, strings.ToLower(ci), command) return liveDocExists(url) } -func liveEventDocExists(ci string, command string) bool { +func (p *kosliLiveDocProvider) EventDocExists(ci, command string) bool { url := fmt.Sprintf("%s/event_exists?ci=%s&command=%s", baseURL, strings.ToLower(ci), command) return liveDocExists(url) } -func liveCliDocExists(command string) (string, string, bool) { +func (p *kosliLiveDocProvider) YamlURL(ci, command string) string { + return fmt.Sprintf("%s/yaml?ci=%s&command=%s", baseURL, strings.ToLower(ci), command) +} + +func (p *kosliLiveDocProvider) EventURL(ci, command string) string { + return fmt.Sprintf("%s/event?ci=%s&command=%s", baseURL, strings.ToLower(ci), command) +} + +func (p *kosliLiveDocProvider) CLIDocExists(command string) (string, string, bool) { fullCommand, ok := liveCliMap[command] if ok { plussed := strings.ReplaceAll(fullCommand, " ", "+") - exists_url := fmt.Sprintf("%s/cli_exists?command=%s", baseURL, plussed) + existsURL := fmt.Sprintf("%s/cli_exists?command=%s", baseURL, plussed) url := fmt.Sprintf("%s/cli?command=%s", baseURL, plussed) - return fullCommand, url, liveDocExists(exists_url) - } else { - return "", "", false + return fullCommand, url, liveDocExists(existsURL) } + return "", "", false } func liveDocExists(url string) bool { - response, err := http.Get(url) + response, err := http.Get(url) //nolint:gosec if err != nil { return false } @@ -331,14 +130,6 @@ func liveDocExists(url string) bool { return exists } -func yamlURL(ci string, command string) string { - return fmt.Sprintf("%s/yaml?ci=%s&command=%s", baseURL, strings.ToLower(ci), command) -} - -func eventURL(ci string, command string) string { - return fmt.Sprintf("%s/event?ci=%s&command=%s", baseURL, strings.ToLower(ci), command) -} - var liveCliMap = map[string]string{ "kosli list environments": "kosli list environments --output=json", "kosli get environment": "kosli get environment aws-prod --output=json", diff --git a/cmd/kosli/docs_test.go b/cmd/kosli/docs_test.go index 2f383f6e0..b2979f7f5 100644 --- a/cmd/kosli/docs_test.go +++ b/cmd/kosli/docs_test.go @@ -24,7 +24,7 @@ func (suite *DocsCommandTestSuite) TestDocsCmd() { // Then: // - make test_integration_single TARGET=TestDocsCommandTestSuite // will tell you where the new snyk.md master file lives. - // Then copy it to ./cmd/kosli/testdata/output/docs/ + // Then copy it to ./cmd/kosli/testdata/output/docs/hugo/ // and undo the changes above. global = &GlobalOpts{} tempDirName, err := os.MkdirTemp("", "generatedDocs") @@ -46,7 +46,57 @@ func (suite *DocsCommandTestSuite) TestDocsCmd() { actualFile := filepath.Join(tempDirName, "snyk.md") require.FileExists(suite.T(), actualFile) - err = compareTwoFiles(actualFile, goldenPath("output/docs/snyk.md")) + err = compareTwoFiles(actualFile, goldenPath("output/docs/hugo/snyk.md")) + require.NoError(suite.T(), err) +} + +func (suite *DocsCommandTestSuite) TestDocsCmdMintlifySnyk() { + global = &GlobalOpts{} + tempDirName, err := os.MkdirTemp("", "generatedDocsMintlify") + require.NoError(suite.T(), err) + defer func() { + if err := os.RemoveAll(tempDirName); err != nil { + require.NoError(suite.T(), err, "failed to remove temp dir %s", tempDirName) + } + }() + + o := &docsOptions{ + dest: tempDirName, + topCmd: newAttestSnykCmd(os.Stdout), + generateHeaders: true, + mintlify: true, + } + err = o.run() + require.NoError(suite.T(), err) + + actualFile := filepath.Join(tempDirName, "snyk.md") + require.FileExists(suite.T(), actualFile) + err = compareTwoFiles(actualFile, goldenPath("output/docs/mintlify/snyk.md")) + require.NoError(suite.T(), err) +} + +func (suite *DocsCommandTestSuite) TestDocsCmdMintlifyDeprecated() { + global = &GlobalOpts{} + tempDirName, err := os.MkdirTemp("", "generatedDocsMintlifyDeprecated") + require.NoError(suite.T(), err) + defer func() { + if err := os.RemoveAll(tempDirName); err != nil { + require.NoError(suite.T(), err, "failed to remove temp dir %s", tempDirName) + } + }() + + o := &docsOptions{ + dest: tempDirName, + topCmd: newReportArtifactCmd(os.Stdout), + generateHeaders: true, + mintlify: true, + } + err = o.run() + require.NoError(suite.T(), err) + + actualFile := filepath.Join(tempDirName, "artifact.md") + require.FileExists(suite.T(), actualFile) + err = compareTwoFiles(actualFile, goldenPath("output/docs/mintlify/artifact.md")) require.NoError(suite.T(), err) } diff --git a/cmd/kosli/printCommandsInTable.go b/cmd/kosli/printCommandsInTable.go deleted file mode 100644 index bf1d2902a..000000000 --- a/cmd/kosli/printCommandsInTable.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "strings" - - "github.com/spf13/pflag" -) - -func CommandsInTable(f *pflag.FlagSet) string { - buf := new(bytes.Buffer) - - lines := make([]string, 0, 100) - - maxlen := 0 - f.VisitAll(func(flag *pflag.Flag) { - if flag.Hidden { - return - } - - line := "" - if flag.Shorthand != "" && flag.ShorthandDeprecated == "" { - line = fmt.Sprintf(" -%s, --%s", flag.Shorthand, flag.Name) - } else { - line = fmt.Sprintf(" --%s", flag.Name) - } - - varname, usage := pflag.UnquoteUsage(flag) - if varname != "" { - line += " " + varname - } - if flag.NoOptDefVal != "" { - switch flag.Value.Type() { - case "string": - line += fmt.Sprintf("[=\"%s\"]", flag.NoOptDefVal) - case "bool": - if flag.NoOptDefVal != "true" { - line += fmt.Sprintf("[=%s]", flag.NoOptDefVal) - } - case "count": - if flag.NoOptDefVal != "+1" { - line += fmt.Sprintf("[=%s]", flag.NoOptDefVal) - } - default: - line += fmt.Sprintf("[=%s]", flag.NoOptDefVal) - } - } - - // This special character will be replaced with spacing once the - // correct alignment is calculated - line += "\x00" - if len(line) > maxlen { - maxlen = len(line) - } - - line += usage - defaultZero := []string{"", "0", "[]", "", "0s", "false"} - - if !stringInSlice(flag.DefValue, defaultZero) { - if flag.Value.Type() == "string" { - line += fmt.Sprintf(" (default %q)", flag.DefValue) - } else { - line += fmt.Sprintf(" (default %s)", flag.DefValue) - } - } - if len(flag.Deprecated) != 0 { - line += fmt.Sprintf(" (DEPRECATED: %s)", flag.Deprecated) - } - - lines = append(lines, line) - }) - - for _, line := range lines { - sidx := strings.Index(line, "\x00") - fmt.Fprintln(buf, "| ", line[:sidx], " | ", line[sidx+1:], " |") - } - - return buf.String() -} - -func stringInSlice(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} diff --git a/cmd/kosli/testdata/output/docs/artifact.md b/cmd/kosli/testdata/output/docs/hugo/artifact.md similarity index 100% rename from cmd/kosli/testdata/output/docs/artifact.md rename to cmd/kosli/testdata/output/docs/hugo/artifact.md diff --git a/cmd/kosli/testdata/output/docs/snyk.md b/cmd/kosli/testdata/output/docs/hugo/snyk.md similarity index 100% rename from cmd/kosli/testdata/output/docs/snyk.md rename to cmd/kosli/testdata/output/docs/hugo/snyk.md diff --git a/cmd/kosli/testdata/output/docs/mintlify/artifact.md b/cmd/kosli/testdata/output/docs/mintlify/artifact.md new file mode 100644 index 000000000..40d6941c5 --- /dev/null +++ b/cmd/kosli/testdata/output/docs/mintlify/artifact.md @@ -0,0 +1,70 @@ +--- +title: "artifact" +beta: false +deprecated: true +description: "Report an artifact creation to a Kosli flow. " +--- + + +**artifact** is deprecated. see kosli attest commands Deprecated commands will be removed in a future release. + +## Synopsis + +```shell +artifact {IMAGE-NAME | FILE-PATH | DIR-PATH} [flags] +``` + +Report an artifact creation to a Kosli flow. + +The artifact fingerprint can be provided directly with the `--fingerprint` flag, or +calculated based on `--artifact-type` flag. + +Artifact type can be one of: "file" for files, "dir" for directories, "oci" for container +images in registries or "docker" for local docker images. + + + +## Flags +| Flag | Description | +| :--- | :--- | +| -t, --artifact-type string | The type of the artifact to calculate its SHA256 fingerprint. One of: [oci, docker, file, dir]. Only required if you want Kosli to calculate the fingerprint for you (i.e. when you don't specify '--fingerprint' on commands that allow it). | +| -b, --build-url string | The url of CI pipeline that built the artifact. (defaulted in some CIs: [docs](/ci-defaults) ). | +| -u, --commit-url string | The url for the git commit that created the artifact. (defaulted in some CIs: [docs](/ci-defaults) ). | +| -D, --dry-run | [optional] Run in dry-run mode. When enabled, no data is sent to Kosli and the CLI exits with 0 exit code regardless of any errors. | +| -x, --exclude strings | [optional] The comma separated list of directories and files to exclude from fingerprinting. Can take glob patterns. Only applicable for --artifact-type dir. | +| -F, --fingerprint string | [conditional] The SHA256 fingerprint of the artifact. Only required if you don't specify '--artifact-type'. | +| -f, --flow string | The Kosli flow name. | +| -g, --git-commit string | [defaulted] The git commit from which the artifact was created. (defaulted in some CIs: [docs](/ci-defaults), otherwise defaults to HEAD ). | +| -h, --help | help for artifact | +| -n, --name string | [optional] Artifact display name, if different from file, image or directory name. | +| --registry-password string | [conditional] The container registry password or access token. Only required if you want to read container image SHA256 digest from a remote container registry. | +| --registry-username string | [conditional] The container registry username. Only required if you want to read container image SHA256 digest from a remote container registry. | +| --repo-root string | [defaulted] The directory where the source git repository is available. (default ".") | + + +## Examples Use Cases + +These examples all assume that the flags `--api-token`, `--org`, `--host`, (and `--flow`, `--trail` when required), are [set/provided](/getting_started/install/#assigning-flags-via-environment-variables). + + + +```shell +kosli report artifact FILE.tgz + --artifact-type file + --build-url https://exampleci.com + --commit-url https://github.com/YourOrg/YourProject/commit/yourCommitShaThatThisArtifactWasBuiltFrom + --git-commit yourCommitShaThatThisArtifactWasBuiltFrom + +``` + + +```shell +kosli report artifact ANOTHER_FILE.txt + --build-url https://exampleci.com + --commit-url https://github.com/YourOrg/YourProject/commit/yourCommitShaThatThisArtifactWasBuiltFrom + --git-commit yourCommitShaThatThisArtifactWasBuiltFrom + --fingerprint yourArtifactFingerprint +``` + + + diff --git a/cmd/kosli/testdata/output/docs/mintlify/snyk.md b/cmd/kosli/testdata/output/docs/mintlify/snyk.md new file mode 100644 index 000000000..c4626754c --- /dev/null +++ b/cmd/kosli/testdata/output/docs/mintlify/snyk.md @@ -0,0 +1,119 @@ +--- +title: "snyk" +beta: false +deprecated: false +description: "Report a snyk attestation to an artifact or a trail in a Kosli flow. " +--- + +## Synopsis + +```shell +snyk [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags] +``` + +Report a snyk attestation to an artifact or a trail in a Kosli flow. +Only SARIF snyk output is accepted. +Snyk output can be for "snyk code test", "snyk container test", or "snyk iac test". + +The `--scan-results` .json file is analyzed and a summary of the scan results are reported to Kosli. + +By default, the `--scan-results` .json file is also uploaded to Kosli's evidence vault. +You can disable that by setting `--upload-results=false` + + +The attestation can be bound to a *trail* using the trail name. +The attestation can be bound to an *artifact* in two ways: +- using the artifact's SHA256 fingerprint which is calculated (based on the `--artifact-type` flag and the artifact name/path argument) or can be provided directly (with the `--fingerprint` flag). +- using the artifact's name in the flow yaml template and the git commit from which the artifact is/will be created. Useful when reporting an attestation before creating/reporting the artifact. + +You can optionally associate the attestation to a git commit using `--commit` (requires access to a git repo). +You can optionally redact some of the git commit data sent to Kosli using `--redact-commit-info`. +Note that when the attestation is reported for an artifact that does not yet exist in Kosli, `--commit` is required to facilitate +binding the attestation to the right artifact. + +## Flags +| Flag | Description | +| :--- | :--- | +| --annotate stringToString | [optional] Annotate the attestation with data using key=value. | +| -t, --artifact-type string | The type of the artifact to calculate its SHA256 fingerprint. One of: [oci, docker, file, dir]. Only required if you want Kosli to calculate the fingerprint for you (i.e. when you don't specify '--fingerprint' on commands that allow it). | +| --attachments strings | [optional] The comma-separated list of paths of attachments for the reported attestation. Attachments can be files or directories. All attachments are compressed and uploaded to Kosli's evidence vault. | +| -g, --commit string | [conditional] The git commit for which the attestation is associated to. Becomes required when reporting an attestation for an artifact before reporting it to Kosli. (defaulted in some CIs: [docs](/ci-defaults) ). | +| --description string | [optional] attestation description | +| -D, --dry-run | [optional] Run in dry-run mode. When enabled, no data is sent to Kosli and the CLI exits with 0 exit code regardless of any errors. | +| -x, --exclude strings | [optional] The comma separated list of directories and files to exclude from fingerprinting. Can take glob patterns. Only applicable for --artifact-type dir. | +| --external-fingerprint stringToString | [optional] A SHA256 fingerprint of an external attachment represented by --external-url. The format is label=fingerprint (labels cannot contain '.' or '='). This flag can be set multiple times. There must be an external url with a matching label for each external fingerprint. | +| --external-url stringToString | [optional] Add labeled reference URL for an external resource. The format is label=url (labels cannot contain '.' or '='). This flag can be set multiple times. If the resource is a file or dir, you can optionally add its fingerprint via --external-fingerprint | +| -F, --fingerprint string | [conditional] The SHA256 fingerprint of the artifact to attach the attestation to. Only required if the attestation is for an artifact and --artifact-type and artifact name/path are not used. | +| -f, --flow string | The Kosli flow name. | +| -h, --help | help for snyk | +| -n, --name string | The name of the attestation as declared in the flow or trail yaml template. | +| -o, --origin-url string | [optional] The url pointing to where the attestation came from or is related. (defaulted to the CI url in some CIs: [docs](/integrations/ci_cd/#defaulted-kosli-command-flags-from-ci-variables) ). | +| --redact-commit-info strings | [optional] The list of commit info to be redacted before sending to Kosli. Allowed values are one or more of [author, message, branch]. | +| --registry-password string | [conditional] The container registry password or access token. Only required if you want to read container image SHA256 digest from a remote container registry. | +| --registry-username string | [conditional] The container registry username. Only required if you want to read container image SHA256 digest from a remote container registry. | +| --repo-root string | [defaulted] The directory where the source git repository is available. Only used if --commit is used or defaulted in CI, see [docs](/integrations/ci_cd/#defaulted-kosli-command-flags-from-ci-variables) . (default ".") | +| -R, --scan-results string | The path to Snyk scan SARIF results file from 'snyk test' and 'snyk container test'. By default, the Snyk results will be uploaded to Kosli's evidence vault. | +| -T, --trail string | The Kosli trail name. | +| --upload-results | [defaulted] Whether to upload the provided Snyk results file as an attachment to Kosli or not. (default true) | +| -u, --user-data string | [optional] The path to a JSON file containing additional data you would like to attach to the attestation. | + + +## Examples Use Cases + +These examples all assume that the flags `--api-token`, `--org`, `--host`, (and `--flow`, `--trail` when required), are [set/provided](/getting_started/install/#assigning-flags-via-environment-variables). + + + +```shell +kosli attest snyk yourDockerImageName + --artifact-type docker + --name yourAttestationName + --scan-results yourSnykSARIFScanResults + +``` + + +```shell +kosli attest snyk + --fingerprint yourDockerImageFingerprint + --name yourAttestationName + --scan-results yourSnykSARIFScanResults + +``` + + +```shell +kosli attest snyk + --name yourAttestationName + --scan-results yourSnykSARIFScanResults + +``` + + +```shell +kosli attest snyk + --name yourTemplateArtifactName.yourAttestationName + --commit yourArtifactGitCommit + --scan-results yourSnykSARIFScanResults + +``` + + +```shell +kosli attest snyk + --name yourAttestationName + --scan-results yourSnykSARIFScanResults + --attachments yourEvidencePathName + +``` + + +```shell +kosli attest snyk + --name yourAttestationName + --scan-results yourSnykSARIFScanResults + --upload-results=false +``` + + + diff --git a/internal/docgen/formatter.go b/internal/docgen/formatter.go new file mode 100644 index 000000000..24039b1ff --- /dev/null +++ b/internal/docgen/formatter.go @@ -0,0 +1,68 @@ +package docgen + +import "github.com/spf13/cobra" + +// CommandMeta holds metadata about a cobra command for doc generation. +type CommandMeta struct { + Name string + Beta bool + Deprecated bool + DeprecMsg string + Summary string + Long string + UseLine string + Runnable bool + Example string +} + +// CIExample holds data for a single CI system's live example. +type CIExample struct { + CI string + YamlURL string + EventURL string +} + +// LiveExampleData holds all live example data for a command. +type LiveExampleData struct { + CIExamples []CIExample + CLICommand string + CLIURL string + CLIExists bool +} + +// Formatter defines the interface for generating doc output in different formats. +type Formatter interface { + Title(name string) string + FrontMatter(meta CommandMeta) string + BetaWarning(name string) string + DeprecatedWarning(name, message string) string + Synopsis(meta CommandMeta) string + FlagsSection(flags, inherited string) string + LiveCIExamples(examples []CIExample, commandName string) string + LiveCLIExample(commandName, fullCommand, url string) string + ExampleUseCases(commandName, example string) string + LinkHandler(name string) string +} + +// CommandMetaFunc is a callback that returns metadata for a cobra command. +// It bridges the cmd/kosli package (which knows about isBeta/isDeprecated) +// with the docgen package. +type CommandMetaFunc func(cmd *cobra.Command) CommandMeta + +// LiveDocProvider abstracts the HTTP calls to check for live documentation. +type LiveDocProvider interface { + YamlDocExists(ci, command string) bool + EventDocExists(ci, command string) bool + YamlURL(ci, command string) string + EventURL(ci, command string) string + CLIDocExists(command string) (fullCommand, url string, exists bool) +} + +// NullLiveDocProvider is a no-op implementation for testing. +type NullLiveDocProvider struct{} + +func (NullLiveDocProvider) YamlDocExists(ci, command string) bool { return false } +func (NullLiveDocProvider) EventDocExists(ci, command string) bool { return false } +func (NullLiveDocProvider) YamlURL(ci, command string) string { return "" } +func (NullLiveDocProvider) EventURL(ci, command string) string { return "" } +func (NullLiveDocProvider) CLIDocExists(command string) (string, string, bool) { return "", "", false } diff --git a/internal/docgen/formatter_test.go b/internal/docgen/formatter_test.go new file mode 100644 index 000000000..3f4daeab7 --- /dev/null +++ b/internal/docgen/formatter_test.go @@ -0,0 +1,28 @@ +package docgen + +import "testing" + +// Compile-time interface satisfaction checks. +var _ Formatter = (*HugoFormatter)(nil) +var _ Formatter = (*MintlifyFormatter)(nil) +var _ LiveDocProvider = (*NullLiveDocProvider)(nil) + +func TestNullLiveDocProvider(t *testing.T) { + p := NullLiveDocProvider{} + if p.YamlDocExists("GitHub", "cmd") { + t.Error("expected false") + } + if p.EventDocExists("GitHub", "cmd") { + t.Error("expected false") + } + if p.YamlURL("GitHub", "cmd") != "" { + t.Error("expected empty string") + } + if p.EventURL("GitHub", "cmd") != "" { + t.Error("expected empty string") + } + fc, u, exists := p.CLIDocExists("cmd") + if fc != "" || u != "" || exists { + t.Error("expected empty/false") + } +} diff --git a/internal/docgen/generate.go b/internal/docgen/generate.go new file mode 100644 index 000000000..8d2cb05c4 --- /dev/null +++ b/internal/docgen/generate.go @@ -0,0 +1,111 @@ +package docgen + +import ( + "fmt" + "io" + "log" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +// GenMarkdownTree walks the cobra command tree and generates a doc file for each +// leaf command using the provided Formatter. +func GenMarkdownTree(cmd *cobra.Command, dir string, formatter Formatter, metaFn CommandMetaFunc, liveDocs LiveDocProvider) error { + for _, c := range cmd.Commands() { + // skip all unavailable commands except deprecated ones + if (!c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand()) && c.Deprecated == "" { + continue + } + if err := GenMarkdownTree(c, dir, formatter, metaFn, liveDocs); err != nil { + return err + } + } + + // Only generate docs for leaf commands (not root, not parent commands) + if !cmd.HasParent() || !cmd.HasSubCommands() { + basename := strings.ReplaceAll(cmd.CommandPath(), " ", "_") + ".md" + filename := filepath.Join(dir, basename) + f, err := os.Create(filename) + if err != nil { + return err + } + defer func() { + if err := f.Close(); err != nil { + log.Printf("warning: failed to close file %s: %v", filename, err) + } + }() + + if err := genMarkdownCustom(cmd, f, formatter, metaFn, liveDocs); err != nil { + return err + } + } + return nil +} + +func genMarkdownCustom(cmd *cobra.Command, w io.Writer, formatter Formatter, metaFn CommandMetaFunc, liveDocs LiveDocProvider) error { + cmd.InitDefaultHelpCmd() + cmd.InitDefaultHelpFlag() + + meta := metaFn(cmd) + name := meta.Name + + var buf strings.Builder + + // Front matter + buf.WriteString(formatter.FrontMatter(meta)) + + // Title + buf.WriteString(formatter.Title(name)) + + // Beta warning + if meta.Beta { + buf.WriteString(formatter.BetaWarning(name)) + } + + // Deprecated warning + if meta.Deprecated { + buf.WriteString(formatter.DeprecatedWarning(name, meta.DeprecMsg)) + } + + // Synopsis + buf.WriteString(formatter.Synopsis(meta)) + + // Flags + flags, inherited := RenderFlagsTables(cmd) + buf.WriteString(formatter.FlagsSection(flags, inherited)) + + // Live CI examples + urlSafeName := url.QueryEscape(name) + var ciExamples []CIExample + for _, ci := range []string{"GitHub", "GitLab"} { + if liveDocs.YamlDocExists(ci, urlSafeName) { + ex := CIExample{ + CI: ci, + YamlURL: liveDocs.YamlURL(ci, urlSafeName), + } + if liveDocs.EventDocExists(ci, urlSafeName) { + ex.EventURL = liveDocs.EventURL(ci, urlSafeName) + } + ciExamples = append(ciExamples, ex) + } + } + buf.WriteString(formatter.LiveCIExamples(ciExamples, name)) + + // Live CLI example + fullCommand, cliURL, cliExists := liveDocs.CLIDocExists(name) + if cliExists { + buf.WriteString(formatter.LiveCLIExample(name, fullCommand, cliURL)) + } + + // Example use cases + if len(meta.Example) > 0 { + buf.WriteString(formatter.ExampleUseCases(name, meta.Example)) + } + + _, err := fmt.Fprint(w, buf.String()) + return err +} diff --git a/internal/docgen/generate_test.go b/internal/docgen/generate_test.go new file mode 100644 index 000000000..cc0d5cb5e --- /dev/null +++ b/internal/docgen/generate_test.go @@ -0,0 +1,164 @@ +package docgen + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestGenMarkdownTreeCreatesFiles(t *testing.T) { + dir := t.TempDir() + + root := &cobra.Command{Use: "root"} + child := &cobra.Command{ + Use: "child", + Short: "A child command", + Long: "A child command with a longer description.", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(child) + + metaFn := func(cmd *cobra.Command) CommandMeta { + return CommandMeta{ + Name: cmd.CommandPath(), + Summary: cmd.Short, + Long: cmd.Long, + UseLine: cmd.UseLine(), + Runnable: cmd.Runnable(), + Example: cmd.Example, + } + } + + err := GenMarkdownTree(root, dir, HugoFormatter{}, metaFn, NullLiveDocProvider{}) + if err != nil { + t.Fatalf("GenMarkdownTree error: %v", err) + } + + // Should create a file for the leaf command + expected := filepath.Join(dir, "root_child.md") + if _, err := os.Stat(expected); os.IsNotExist(err) { + t.Errorf("expected file %s to be created", expected) + } + + content, err := os.ReadFile(expected) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + if !strings.Contains(string(content), "# root child") { + t.Error("expected command name as heading") + } + if !strings.Contains(string(content), "## Synopsis") { + t.Error("expected Synopsis section") + } +} + +func TestGenMarkdownTreeSkipsHiddenCommands(t *testing.T) { + dir := t.TempDir() + + root := &cobra.Command{Use: "root"} + hidden := &cobra.Command{ + Use: "hidden", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(hidden) + + metaFn := func(cmd *cobra.Command) CommandMeta { + return CommandMeta{Name: cmd.CommandPath()} + } + + err := GenMarkdownTree(root, dir, HugoFormatter{}, metaFn, NullLiveDocProvider{}) + if err != nil { + t.Fatalf("GenMarkdownTree error: %v", err) + } + + unexpected := filepath.Join(dir, "root_hidden.md") + if _, err := os.Stat(unexpected); !os.IsNotExist(err) { + t.Error("expected hidden command to be skipped") + } +} + +func TestGenMarkdownTreeIncludesDeprecatedCommands(t *testing.T) { + dir := t.TempDir() + + root := &cobra.Command{Use: "root"} + deprecated := &cobra.Command{ + Use: "deprecated", + Short: "A deprecated cmd", + Long: "Deprecated long desc.", + Deprecated: "use something else", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(deprecated) + + metaFn := func(cmd *cobra.Command) CommandMeta { + return CommandMeta{ + Name: cmd.CommandPath(), + Deprecated: cmd.Deprecated != "", + DeprecMsg: cmd.Deprecated, + Summary: cmd.Short, + Long: cmd.Long, + UseLine: cmd.UseLine(), + Runnable: cmd.Runnable(), + } + } + + err := GenMarkdownTree(root, dir, HugoFormatter{}, metaFn, NullLiveDocProvider{}) + if err != nil { + t.Fatalf("GenMarkdownTree error: %v", err) + } + + expected := filepath.Join(dir, "root_deprecated.md") + if _, err := os.Stat(expected); os.IsNotExist(err) { + t.Error("expected deprecated command to be included") + } + + content, err := os.ReadFile(expected) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + if !strings.Contains(string(content), "hint danger") { + t.Error("expected deprecation warning in output") + } +} + +func TestGenMarkdownTreeWithMintlifyFormatter(t *testing.T) { + dir := t.TempDir() + + root := &cobra.Command{Use: "root"} + child := &cobra.Command{ + Use: "child", + Short: "A child command", + Long: "A child command with a longer description.", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(child) + + metaFn := func(cmd *cobra.Command) CommandMeta { + return CommandMeta{ + Name: cmd.CommandPath(), + Summary: cmd.Short, + Long: cmd.Long, + UseLine: cmd.UseLine(), + Runnable: cmd.Runnable(), + } + } + + err := GenMarkdownTree(root, dir, MintlifyFormatter{}, metaFn, NullLiveDocProvider{}) + if err != nil { + t.Fatalf("GenMarkdownTree error: %v", err) + } + + expected := filepath.Join(dir, "root_child.md") + content, err := os.ReadFile(expected) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + s := string(content) + if !strings.Contains(s, "description:") { + t.Error("expected Mintlify description in front matter") + } +} diff --git a/internal/docgen/helpers.go b/internal/docgen/helpers.go new file mode 100644 index 000000000..ffdaf8d23 --- /dev/null +++ b/internal/docgen/helpers.go @@ -0,0 +1,132 @@ +package docgen + +import ( + "bytes" + "fmt" + "slices" + "strings" + "unicode" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// CommandsInTable renders a pflag.FlagSet as markdown table rows. +func CommandsInTable(f *pflag.FlagSet) string { + buf := new(bytes.Buffer) + + lines := make([]string, 0, 100) + + maxlen := 0 + f.VisitAll(func(flag *pflag.Flag) { + if flag.Hidden { + return + } + + line := "" + if flag.Shorthand != "" && flag.ShorthandDeprecated == "" { + line = fmt.Sprintf(" -%s, --%s", flag.Shorthand, flag.Name) + } else { + line = fmt.Sprintf(" --%s", flag.Name) + } + + varname, usage := pflag.UnquoteUsage(flag) + if varname != "" { + line += " " + varname + } + if flag.NoOptDefVal != "" { + switch flag.Value.Type() { + case "string": + line += fmt.Sprintf("[=\"%s\"]", flag.NoOptDefVal) + case "bool": + if flag.NoOptDefVal != "true" { + line += fmt.Sprintf("[=%s]", flag.NoOptDefVal) + } + case "count": + if flag.NoOptDefVal != "+1" { + line += fmt.Sprintf("[=%s]", flag.NoOptDefVal) + } + default: + line += fmt.Sprintf("[=%s]", flag.NoOptDefVal) + } + } + + line += "\x00" + if len(line) > maxlen { + maxlen = len(line) + } + + line += usage + defaultZero := []string{"", "0", "[]", "", "0s", "false"} + + if !slices.Contains(defaultZero, flag.DefValue) { + if flag.Value.Type() == "string" { + line += fmt.Sprintf(" (default %q)", flag.DefValue) + } else { + line += fmt.Sprintf(" (default %s)", flag.DefValue) + } + } + if len(flag.Deprecated) != 0 { + line += fmt.Sprintf(" (DEPRECATED: %s)", flag.Deprecated) + } + + lines = append(lines, line) + }) + + for _, line := range lines { + sidx := strings.Index(line, "\x00") + fmt.Fprintln(buf, "| ", line[:sidx], " | ", line[sidx+1:], " |") + } + + return buf.String() +} + +// RenderFlagsTables returns the rendered flag tables for a command's own flags +// and its inherited flags as separate strings. +func RenderFlagsTables(cmd *cobra.Command) (flags, inherited string) { + f := cmd.NonInheritedFlags() + if f.HasAvailableFlags() { + flags = CommandsInTable(f) + } + pf := cmd.InheritedFlags() + if pf.HasAvailableFlags() { + inherited = CommandsInTable(pf) + } + return +} + +// HashTitledExamples splits example lines into groups where each group starts +// with a line beginning with '#'. +func HashTitledExamples(lines []string) [][]string { + result := make([][]string, 0) + example := make([]string, 0) + for _, line := range lines { + if strings.HasPrefix(line, "#") { + result = append(result, example) + example = make([]string, 0) + } + if !IsSetWithEnvVar(line) { + example = append(example, ChoppedLineContinuation(line)) + } + } + result = append(result, example) + return result[1:] +} + +// IsSetWithEnvVar returns true if the line sets a flag that is typically +// provided via environment variable. +func IsSetWithEnvVar(line string) bool { + trimmed := strings.TrimSpace(line) + for _, prefix := range []string{"--api-token ", "--host ", "--org ", "--flow ", "--trail "} { + if strings.HasPrefix(trimmed, prefix) { + return true + } + } + return false +} + +// ChoppedLineContinuation trims trailing whitespace and removes trailing backslash. +func ChoppedLineContinuation(line string) string { + trimmed := strings.TrimRightFunc(line, unicode.IsSpace) + return strings.TrimSuffix(trimmed, "\\") +} diff --git a/internal/docgen/helpers_test.go b/internal/docgen/helpers_test.go new file mode 100644 index 000000000..a2fb8ef90 --- /dev/null +++ b/internal/docgen/helpers_test.go @@ -0,0 +1,127 @@ +package docgen + +import ( + "strings" + "testing" + + "github.com/spf13/pflag" +) + +func TestCommandsInTable(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.StringP("name", "n", "", "The name") + fs.Bool("verbose", false, "Enable verbose") + + got := CommandsInTable(fs) + if !strings.Contains(got, "--name") { + t.Error("expected --name flag") + } + if !strings.Contains(got, "-n") { + t.Error("expected shorthand -n") + } + if !strings.Contains(got, "--verbose") { + t.Error("expected --verbose flag") + } + if !strings.Contains(got, "|") { + t.Error("expected table formatting") + } +} + +func TestCommandsInTableHiddenFlags(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.String("visible", "", "Visible flag") + fs.String("hidden", "", "Hidden flag") + _ = fs.MarkHidden("hidden") + + got := CommandsInTable(fs) + if !strings.Contains(got, "--visible") { + t.Error("expected --visible flag") + } + if strings.Contains(got, "--hidden") { + t.Error("should not contain hidden flag") + } +} + +func TestCommandsInTableDefaultValues(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.String("dir", "/tmp", "The directory") + + got := CommandsInTable(fs) + if !strings.Contains(got, `(default "/tmp")`) { + t.Errorf("expected default value, got:\n%s", got) + } +} + +func TestHashTitledExamples(t *testing.T) { + lines := []string{ + "# first example", + "kosli attest snyk foo", + "# second example", + "kosli attest snyk bar", + } + groups := HashTitledExamples(lines) + if len(groups) != 2 { + t.Fatalf("expected 2 groups, got %d", len(groups)) + } + if groups[0][0] != "# first example" { + t.Errorf("expected first title, got: %s", groups[0][0]) + } + if groups[0][1] != "kosli attest snyk foo" { + t.Errorf("expected first command, got: %s", groups[0][1]) + } +} + +func TestHashTitledExamplesFiltersEnvVars(t *testing.T) { + lines := []string{ + "# example", + "kosli attest snyk foo", + " --api-token yourToken", + " --org yourOrg", + } + groups := HashTitledExamples(lines) + if len(groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(groups)) + } + // Should only have title + the kosli command (env var lines filtered) + if len(groups[0]) != 2 { + t.Errorf("expected 2 lines (title + command), got %d", len(groups[0])) + } +} + +func TestIsSetWithEnvVar(t *testing.T) { + tests := []struct { + line string + want bool + }{ + {" --api-token yourToken", true}, + {" --host https://app.kosli.com", true}, + {" --org yourOrg", true}, + {" --flow yourFlow", true}, + {" --trail yourTrail", true}, + {" --name foo", false}, + {"kosli attest snyk", false}, + } + for _, tt := range tests { + got := IsSetWithEnvVar(tt.line) + if got != tt.want { + t.Errorf("IsSetWithEnvVar(%q) = %v, want %v", tt.line, got, tt.want) + } + } +} + +func TestChoppedLineContinuation(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"kosli attest snyk foo \\", "kosli attest snyk foo "}, + {"kosli attest snyk foo", "kosli attest snyk foo"}, + {"kosli attest snyk foo \t", "kosli attest snyk foo"}, + } + for _, tt := range tests { + got := ChoppedLineContinuation(tt.input) + if got != tt.want { + t.Errorf("ChoppedLineContinuation(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/docgen/hugo.go b/internal/docgen/hugo.go new file mode 100644 index 000000000..daa182c9b --- /dev/null +++ b/internal/docgen/hugo.go @@ -0,0 +1,139 @@ +package docgen + +import ( + "fmt" + "path" + "strings" +) + +// HugoFormatter generates Hugo-compatible markdown documentation. +type HugoFormatter struct{} + +func (HugoFormatter) Title(name string) string { + return "# " + name + "\n\n" +} + +func (HugoFormatter) FrontMatter(meta CommandMeta) string { + return fmt.Sprintf("---\ntitle: \"%s\"\nbeta: %t\ndeprecated: %t\nsummary: \"%s\"\n---\n\n", + meta.Name, meta.Beta, meta.Deprecated, meta.Summary) +} + +func (HugoFormatter) BetaWarning(name string) string { + var b strings.Builder + b.WriteString("{{% hint warning %}}\n") + fmt.Fprintf(&b, "**%s** is a beta feature. ", name) + fmt.Fprintf(&b, "Beta features provide early access to product functionality. ") + fmt.Fprintf(&b, "These features may change between releases without warning, or can be removed in a ") + fmt.Fprintf(&b, "future release.\n") + fmt.Fprintf(&b, "Please contact us to enable this feature for your organization.\n") + b.WriteString("{{% /hint %}}\n") + return b.String() +} + +func (HugoFormatter) DeprecatedWarning(name, message string) string { + var b strings.Builder + b.WriteString("{{% hint danger %}}\n") + fmt.Fprintf(&b, "**%s** is deprecated. %s ", name, message) + fmt.Fprintf(&b, "Deprecated commands will be removed in a future release.\n") + b.WriteString("{{% /hint %}}\n") + return b.String() +} + +func (HugoFormatter) Synopsis(meta CommandMeta) string { + var b strings.Builder + if len(meta.Long) > 0 { + b.WriteString("## Synopsis\n\n") + if meta.Runnable { + fmt.Fprintf(&b, "```shell\n%s\n```\n\n", meta.UseLine) + } + b.WriteString(strings.ReplaceAll(meta.Long, "^", "`") + "\n\n") + } + return b.String() +} + +func (HugoFormatter) FlagsSection(flags, inherited string) string { + var b strings.Builder + if flags != "" { + b.WriteString("## Flags\n") + b.WriteString("| Flag | Description |\n") + b.WriteString("| :--- | :--- |\n") + b.WriteString(flags) + b.WriteString("\n\n") + } + if inherited != "" { + b.WriteString("## Flags inherited from parent commands\n") + b.WriteString("| Flag | Description |\n") + b.WriteString("| :--- | :--- |\n") + b.WriteString(inherited) + b.WriteString("\n\n") + } + return b.String() +} + +func (HugoFormatter) LiveCIExamples(examples []CIExample, commandName string) string { + if len(examples) == 0 { + return "" + } + var b strings.Builder + b.WriteString("## Live Examples in different CI systems\n\n") + b.WriteString("{{< tabs \"live-examples\" \"col-no-wrap\" >}}") + for _, ex := range examples { + fmt.Fprintf(&b, "{{< tab \"%v\" >}}", ex.CI) + fmt.Fprintf(&b, "View an example of the `%s` command in %s.\n\n", commandName, ex.CI) + fmt.Fprintf(&b, "In [this YAML file](%v)", ex.YamlURL) + if ex.EventURL != "" { + fmt.Fprintf(&b, ", which created [this Kosli Event](%v).", ex.EventURL) + } + b.WriteString("{{< /tab >}}") + } + b.WriteString("{{< /tabs >}}\n\n") + return b.String() +} + +func (HugoFormatter) LiveCLIExample(commandName, fullCommand, url string) string { + var b strings.Builder + b.WriteString("## Live Example\n\n") + b.WriteString("{{< raw-html >}}") + fmt.Fprintf(&b, "To view a live example of '%s' you can run the commands below (for the cyber-dojo demo organization).
Run the commands below and view the output.", commandName, url) + b.WriteString("
")
+	b.WriteString("export KOSLI_ORG=cyber-dojo\n")
+	b.WriteString("export KOSLI_API_TOKEN=Pj_XT2deaVA6V1qrTlthuaWsmjVt4eaHQwqnwqjRO3A  # read-only\n")
+	b.WriteString(fullCommand)
+	b.WriteString("
") + b.WriteString("{{< / raw-html >}}\n\n") + return b.String() +} + +func (HugoFormatter) ExampleUseCases(commandName, example string) string { + var b strings.Builder + b.WriteString("## Examples Use Cases\n\n") + url := "https://docs.kosli.com/getting_started/install/#assigning-flags-via-environment-variables" + fmt.Fprintf(&b, "These examples all assume that the flags `--api-token`, `--org`, `--host`, (and `--flow`, `--trail` when required), are [set/provided](%v). \n\n", url) + + example = strings.TrimSpace(example) + lines := strings.Split(example, "\n") + + if commandName == "kosli report approval" || + commandName == "kosli request approval" || + commandName == "kosli snapshot server" { + fmt.Fprintf(&b, "```shell\n%s\n```\n\n", example) + } else if lines[0][0] != '#' { + fmt.Fprintf(&b, "```shell\n%s\n```\n\n", example) + } else { + all := HashTitledExamples(lines) + for i := 0; i < len(all); i++ { + exampleLines := all[i] + title := strings.Trim(exampleLines[0], ":") + if len(title) > 0 { + fmt.Fprintf(&b, "##### %s\n\n", strings.TrimSpace(title[1:])) + fmt.Fprintf(&b, "```shell\n%s\n```\n\n", strings.Join(exampleLines[1:], "\n")) + } + } + } + return b.String() +} + +func (HugoFormatter) LinkHandler(name string) string { + base := strings.TrimSuffix(name, path.Ext(name)) + return "/client_reference/" + strings.ToLower(base) + "/" +} diff --git a/internal/docgen/hugo_test.go b/internal/docgen/hugo_test.go new file mode 100644 index 000000000..ef8184a23 --- /dev/null +++ b/internal/docgen/hugo_test.go @@ -0,0 +1,127 @@ +package docgen + +import ( + "strings" + "testing" +) + +func TestHugoFrontMatter(t *testing.T) { + f := HugoFormatter{} + meta := CommandMeta{Name: "kosli attest snyk", Summary: "Report a snyk attestation"} + got := f.FrontMatter(meta) + if !strings.Contains(got, `title: "kosli attest snyk"`) { + t.Errorf("expected title in front matter, got:\n%s", got) + } + if !strings.Contains(got, "beta: false") { + t.Errorf("expected beta: false, got:\n%s", got) + } + if !strings.Contains(got, `summary: "Report a snyk attestation"`) { + t.Errorf("expected summary, got:\n%s", got) + } +} + +func TestHugoBetaWarning(t *testing.T) { + f := HugoFormatter{} + got := f.BetaWarning("kosli foo") + if !strings.Contains(got, "{{% hint warning %}}") { + t.Error("expected Hugo hint warning shortcode") + } + if !strings.Contains(got, "**kosli foo** is a beta feature") { + t.Error("expected command name in warning") + } +} + +func TestHugoDeprecatedWarning(t *testing.T) { + f := HugoFormatter{} + got := f.DeprecatedWarning("kosli artifact", "see kosli attest commands") + if !strings.Contains(got, "{{% hint danger %}}") { + t.Error("expected Hugo hint danger shortcode") + } + if !strings.Contains(got, "**kosli artifact** is deprecated. see kosli attest commands") { + t.Error("expected deprecation message") + } +} + +func TestHugoSynopsis(t *testing.T) { + f := HugoFormatter{} + meta := CommandMeta{ + Long: "Report a ^snyk^ attestation.", + UseLine: "snyk [IMAGE-NAME] [flags]", + Runnable: true, + } + got := f.Synopsis(meta) + if !strings.Contains(got, "## Synopsis") { + t.Error("expected Synopsis heading") + } + if !strings.Contains(got, "```shell\nsnyk [IMAGE-NAME] [flags]\n```") { + t.Error("expected shell code block with usage line") + } + if !strings.Contains(got, "Report a `snyk` attestation.") { + t.Error("expected carets replaced with backticks") + } +} + +func TestHugoSynopsisNotRunnable(t *testing.T) { + f := HugoFormatter{} + meta := CommandMeta{Long: "Some description", Runnable: false} + got := f.Synopsis(meta) + if strings.Contains(got, "```shell") { + t.Error("should not contain code block for non-runnable command") + } +} + +func TestHugoLiveCIExamples(t *testing.T) { + f := HugoFormatter{} + examples := []CIExample{ + {CI: "GitHub", YamlURL: "http://yaml", EventURL: "http://event"}, + } + got := f.LiveCIExamples(examples, "kosli attest snyk") + if !strings.Contains(got, `{{< tabs "live-examples"`) { + t.Error("expected Hugo tabs shortcode") + } + if !strings.Contains(got, `{{< tab "GitHub" >}}`) { + t.Error("expected GitHub tab") + } +} + +func TestHugoLiveCIExamplesEmpty(t *testing.T) { + f := HugoFormatter{} + got := f.LiveCIExamples(nil, "cmd") + if got != "" { + t.Error("expected empty string for no examples") + } +} + +func TestHugoLiveCLIExample(t *testing.T) { + f := HugoFormatter{} + got := f.LiveCLIExample("kosli list environments", "kosli list environments --output=json", "http://example.com") + if !strings.Contains(got, "{{< raw-html >}}") { + t.Error("expected raw-html shortcode") + } + if !strings.Contains(got, "kosli list environments --output=json") { + t.Error("expected CLI command in output") + } +} + +func TestHugoExampleUseCases(t *testing.T) { + f := HugoFormatter{} + example := "# report a snyk attestation\nkosli attest snyk foo" + got := f.ExampleUseCases("kosli attest snyk", example) + if !strings.Contains(got, "## Examples Use Cases") { + t.Error("expected heading") + } + if !strings.Contains(got, "##### report a snyk attestation") { + t.Error("expected hash-titled example") + } + if !strings.Contains(got, "https://docs.kosli.com/getting_started") { + t.Error("expected full URL for Hugo format") + } +} + +func TestHugoLinkHandler(t *testing.T) { + f := HugoFormatter{} + got := f.LinkHandler("kosli_attest_snyk.md") + if got != "/client_reference/kosli_attest_snyk/" { + t.Errorf("expected trailing slash link, got: %s", got) + } +} diff --git a/internal/docgen/mintlify.go b/internal/docgen/mintlify.go new file mode 100644 index 000000000..bcb5b63e8 --- /dev/null +++ b/internal/docgen/mintlify.go @@ -0,0 +1,192 @@ +package docgen + +import ( + "fmt" + "path" + "regexp" + "strings" +) + +// MintlifyFormatter generates Mintlify-compatible MDX documentation. +type MintlifyFormatter struct{} + +func (MintlifyFormatter) Title(name string) string { + return "" // Mintlify renders title from front matter +} + +func (MintlifyFormatter) FrontMatter(meta CommandMeta) string { + desc := sanitizeDescription(meta.Summary) + return fmt.Sprintf("---\ntitle: \"%s\"\nbeta: %t\ndeprecated: %t\ndescription: \"%s\"\n---\n\n", + meta.Name, meta.Beta, meta.Deprecated, desc) +} + +func (MintlifyFormatter) BetaWarning(name string) string { + var b strings.Builder + b.WriteString("\n") + fmt.Fprintf(&b, "**%s** is a beta feature. ", name) + fmt.Fprintf(&b, "Beta features provide early access to product functionality. ") + fmt.Fprintf(&b, "These features may change between releases without warning, or can be removed in a ") + fmt.Fprintf(&b, "future release.\n") + fmt.Fprintf(&b, "Please contact us to enable this feature for your organization.\n") + b.WriteString("\n") + return b.String() +} + +func (MintlifyFormatter) DeprecatedWarning(name, message string) string { + var b strings.Builder + b.WriteString("\n") + fmt.Fprintf(&b, "**%s** is deprecated. %s ", name, message) + fmt.Fprintf(&b, "Deprecated commands will be removed in a future release.\n") + b.WriteString("\n") + return b.String() +} + +func (MintlifyFormatter) Synopsis(meta CommandMeta) string { + var b strings.Builder + if len(meta.Long) > 0 { + b.WriteString("## Synopsis\n\n") + if meta.Runnable { + fmt.Fprintf(&b, "```shell\n%s\n```\n\n", meta.UseLine) + } + long := strings.ReplaceAll(meta.Long, "^", "`") + long = linkifyKosliDocsURLs(long) + b.WriteString(escapeMintlifyProse(long) + "\n\n") + } + return b.String() +} + +func (MintlifyFormatter) FlagsSection(flags, inherited string) string { + flags = linkifyKosliDocsURLs(flags) + inherited = linkifyKosliDocsURLs(inherited) + var b strings.Builder + if flags != "" { + b.WriteString("## Flags\n") + b.WriteString("| Flag | Description |\n") + b.WriteString("| :--- | :--- |\n") + b.WriteString(flags) + b.WriteString("\n\n") + } + if inherited != "" { + b.WriteString("## Flags inherited from parent commands\n") + b.WriteString("| Flag | Description |\n") + b.WriteString("| :--- | :--- |\n") + b.WriteString(inherited) + b.WriteString("\n\n") + } + return b.String() +} + +func (MintlifyFormatter) LiveCIExamples(examples []CIExample, commandName string) string { + if len(examples) == 0 { + return "" + } + var b strings.Builder + b.WriteString("## Live Examples in different CI systems\n\n") + b.WriteString("\n") + for _, ex := range examples { + fmt.Fprintf(&b, "\t\n", ex.CI) + fmt.Fprintf(&b, "\tView an example of the `%s` command in %s.\n\n", commandName, ex.CI) + fmt.Fprintf(&b, "\tIn [this YAML file](%v)", ex.YamlURL) + if ex.EventURL != "" { + fmt.Fprintf(&b, ", which created [this Kosli Event](%v).", ex.EventURL) + } + b.WriteString("\n\t\n") + } + b.WriteString("\n\n") + return b.String() +} + +func (MintlifyFormatter) LiveCLIExample(commandName, fullCommand, url string) string { + var b strings.Builder + b.WriteString("## Live Example\n\n") + fmt.Fprintf(&b, "To view a live example of '%s' you can run the commands below (for the cyber-dojo demo organization).
Run the commands below and view the output.", commandName, url) + b.WriteString("
")
+	b.WriteString("export KOSLI_ORG=cyber-dojo\n")
+	b.WriteString("export KOSLI_API_TOKEN=Pj_XT2deaVA6V1qrTlthuaWsmjVt4eaHQwqnwqjRO3A  # read-only\n")
+	b.WriteString(fullCommand)
+	b.WriteString("
\n\n") + return b.String() +} + +func (MintlifyFormatter) ExampleUseCases(commandName, example string) string { + var b strings.Builder + b.WriteString("## Examples Use Cases\n\n") + url := "/getting_started/install/#assigning-flags-via-environment-variables" + fmt.Fprintf(&b, "These examples all assume that the flags `--api-token`, `--org`, `--host`, (and `--flow`, `--trail` when required), are [set/provided](%v). \n\n", url) + + example = strings.TrimSpace(example) + lines := strings.Split(example, "\n") + + if commandName == "kosli report approval" || + commandName == "kosli request approval" || + commandName == "kosli snapshot server" { + fmt.Fprintf(&b, "```shell\n%s\n```\n\n", example) + } else if lines[0][0] != '#' { + fmt.Fprintf(&b, "```shell\n%s\n```\n\n", example) + } else { + all := HashTitledExamples(lines) + b.WriteString("\n") + for i := 0; i < len(all); i++ { + exampleLines := all[i] + title := strings.Trim(exampleLines[0], ":") + if len(title) > 0 { + fmt.Fprintf(&b, "\n", strings.TrimSpace(title[1:])) + fmt.Fprintf(&b, "```shell\n%s\n```\n", strings.Join(exampleLines[1:], "\n")) + b.WriteString("\n") + } + } + b.WriteString("\n\n") + } + return b.String() +} + +func (MintlifyFormatter) LinkHandler(name string) string { + base := strings.TrimSuffix(name, path.Ext(name)) + return "/client_reference/" + strings.ToLower(base) +} + +// sanitizeDescription replaces ^text^ with `text` and truncates to 200 chars. +func sanitizeDescription(s string) string { + s = strings.ReplaceAll(s, "^", "`") + s = strings.ReplaceAll(s, "\"", "'") + if len(s) > 200 { + s = s[:197] + "..." + } + return s +} + +// escapeMintlifyProse escapes JSX-problematic characters in prose text +// (outside of code fences). It converts {expr} to \{expr\} and to `WORD`. +func escapeMintlifyProse(s string) string { + // Split on code fences to only escape prose sections + parts := strings.Split(s, "```") + for i := 0; i < len(parts); i += 2 { + // Only process prose sections (even indices) + if i < len(parts) { + parts[i] = escapeProseFragment(parts[i]) + } + } + return strings.Join(parts, "```") +} + +var kosliDocsURLPattern = regexp.MustCompile(`https://docs\.kosli\.com(/[^\s),]*)`) + +// linkifyKosliDocsURLs converts bare https://docs.kosli.com/path URLs +// into markdown links [docs](/path). +func linkifyKosliDocsURLs(s string) string { + return kosliDocsURLPattern.ReplaceAllString(s, "[docs]($1)") +} + +var angleBracketPattern = regexp.MustCompile(`<([A-Z][A-Z0-9_-]*)>`) + +func escapeProseFragment(s string) string { + // Escape curly braces: {expr} -> \{expr\} + s = strings.ReplaceAll(s, "{", "\\{") + s = strings.ReplaceAll(s, "}", "\\}") + + // Escape patterns -> `UPPERCASE_WORD` + // but leave HTML tags like ,
,
 alone
+	s = angleBracketPattern.ReplaceAllString(s, "`$1`")
+
+	return s
+}
diff --git a/internal/docgen/mintlify_test.go b/internal/docgen/mintlify_test.go
new file mode 100644
index 000000000..f64b9c4ff
--- /dev/null
+++ b/internal/docgen/mintlify_test.go
@@ -0,0 +1,248 @@
+package docgen
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestMintlifyFrontMatter(t *testing.T) {
+	f := MintlifyFormatter{}
+	meta := CommandMeta{Name: "kosli attest snyk", Summary: "Report a snyk attestation"}
+	got := f.FrontMatter(meta)
+	if !strings.Contains(got, `title: "kosli attest snyk"`) {
+		t.Errorf("expected title, got:\n%s", got)
+	}
+	if !strings.Contains(got, `description: "Report a snyk attestation"`) {
+		t.Errorf("expected description, got:\n%s", got)
+	}
+}
+
+func TestMintlifyFrontMatterSanitizesDescription(t *testing.T) {
+	f := MintlifyFormatter{}
+	meta := CommandMeta{Name: "cmd", Summary: "Use ^foo^ with \"quotes\""}
+	got := f.FrontMatter(meta)
+	if !strings.Contains(got, "description: \"Use `foo` with 'quotes'\"") {
+		t.Errorf("expected sanitized description, got:\n%s", got)
+	}
+}
+
+func TestMintlifyFrontMatterTruncatesLongDescription(t *testing.T) {
+	f := MintlifyFormatter{}
+	long := strings.Repeat("a", 250)
+	meta := CommandMeta{Name: "cmd", Summary: long}
+	got := f.FrontMatter(meta)
+	if !strings.Contains(got, "...") {
+		t.Error("expected truncated description")
+	}
+}
+
+func TestMintlifyBetaWarning(t *testing.T) {
+	f := MintlifyFormatter{}
+	got := f.BetaWarning("kosli foo")
+	if !strings.Contains(got, "") {
+		t.Error("expected Warning component")
+	}
+	if !strings.Contains(got, "") {
+		t.Error("expected closing Warning component")
+	}
+	if !strings.Contains(got, "**kosli foo** is a beta feature") {
+		t.Error("expected command name in warning")
+	}
+}
+
+func TestMintlifyDeprecatedWarning(t *testing.T) {
+	f := MintlifyFormatter{}
+	got := f.DeprecatedWarning("kosli artifact", "see kosli attest commands")
+	if !strings.Contains(got, "") {
+		t.Error("expected Warning component")
+	}
+	if !strings.Contains(got, "**kosli artifact** is deprecated") {
+		t.Error("expected deprecation message")
+	}
+}
+
+func TestMintlifySynopsis(t *testing.T) {
+	f := MintlifyFormatter{}
+	meta := CommandMeta{
+		Long:     "Report a ^snyk^ attestation.",
+		UseLine:  "snyk [IMAGE-NAME] [flags]",
+		Runnable: true,
+	}
+	got := f.Synopsis(meta)
+	if !strings.Contains(got, "## Synopsis") {
+		t.Error("expected Synopsis heading")
+	}
+	if !strings.Contains(got, "Report a `snyk` attestation.") {
+		t.Error("expected carets replaced")
+	}
+}
+
+func TestMintlifyLiveCIExamples(t *testing.T) {
+	f := MintlifyFormatter{}
+	examples := []CIExample{
+		{CI: "GitHub", YamlURL: "http://yaml", EventURL: "http://event"},
+		{CI: "GitLab", YamlURL: "http://yaml2"},
+	}
+	got := f.LiveCIExamples(examples, "kosli attest snyk")
+	if !strings.Contains(got, "") {
+		t.Error("expected Tabs component")
+	}
+	if !strings.Contains(got, ``) {
+		t.Error("expected GitHub tab")
+	}
+	if !strings.Contains(got, ``) {
+		t.Error("expected GitLab tab")
+	}
+	if !strings.Contains(got, "") {
+		t.Error("expected closing Tabs")
+	}
+}
+
+func TestMintlifyLiveCLIExample(t *testing.T) {
+	f := MintlifyFormatter{}
+	got := f.LiveCLIExample("kosli list environments", "kosli list environments --output=json", "http://example.com")
+	// Should NOT contain Hugo shortcode wrappers
+	if strings.Contains(got, "{{< raw-html >}}") {
+		t.Error("should not contain Hugo shortcode")
+	}
+	if !strings.Contains(got, "
") {
+		t.Error("expected raw HTML pre tag")
+	}
+}
+
+func TestMintlifyExampleUseCases(t *testing.T) {
+	f := MintlifyFormatter{}
+	example := "# report a snyk attestation\nkosli attest snyk foo"
+	got := f.ExampleUseCases("kosli attest snyk", example)
+	if !strings.Contains(got, "") {
+		t.Error("expected AccordionGroup component")
+	}
+	if !strings.Contains(got, ``) {
+		t.Error("expected Accordion with title")
+	}
+	if !strings.Contains(got, "") {
+		t.Error("expected closing AccordionGroup")
+	}
+	if strings.Contains(got, "https://docs.kosli.com") {
+		t.Error("expected bare docs.kosli.com URLs to be linkified")
+	}
+}
+
+func TestMintlifyLinkHandler(t *testing.T) {
+	f := MintlifyFormatter{}
+	got := f.LinkHandler("kosli_attest_snyk.md")
+	if got != "/client_reference/kosli_attest_snyk" {
+		t.Errorf("expected no trailing slash, got: %s", got)
+	}
+}
+
+func TestLinkifyKosliDocsURLs(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  string
+	}{
+		{
+			name:  "bare URL becomes markdown link",
+			input: "A boolean flag https://docs.kosli.com/faq/#boolean-flags (default false)",
+			want:  "A boolean flag [docs](/faq/#boolean-flags) (default false)",
+		},
+		{
+			name:  "URL followed by comma",
+			input: "(defaulted in some CIs: https://docs.kosli.com/ci-defaults, otherwise defaults to HEAD ).",
+			want:  "(defaulted in some CIs: [docs](/ci-defaults), otherwise defaults to HEAD ).",
+		},
+		{
+			name:  "URL followed by space and closing paren",
+			input: "(defaulted in some CIs: https://docs.kosli.com/ci-defaults ).",
+			want:  "(defaulted in some CIs: [docs](/ci-defaults) ).",
+		},
+		{
+			name:  "long path URL",
+			input: "see https://docs.kosli.com/integrations/ci_cd/#defaulted-kosli-command-flags-from-ci-variables .",
+			want:  "see [docs](/integrations/ci_cd/#defaulted-kosli-command-flags-from-ci-variables) .",
+		},
+		{
+			name:  "non-kosli URL untouched",
+			input: "see https://example.com/foo for details",
+			want:  "see https://example.com/foo for details",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := linkifyKosliDocsURLs(tt.input)
+			if got != tt.want {
+				t.Errorf("linkifyKosliDocsURLs(%q):\ngot:  %q\nwant: %q", tt.input, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestEscapeMintlifyProse(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  string
+	}{
+		{
+			name:  "curly braces escaped",
+			input: "Use {expression} here",
+			want:  "Use \\{expression\\} here",
+		},
+		{
+			name:  "uppercase angle brackets converted",
+			input: "Use  as input",
+			want:  "Use `IMAGE-NAME` as input",
+		},
+		{
+			name:  "lowercase HTML tags preserved",
+			input: "Use link",
+			want:  "Use link",
+		},
+		{
+			name:  "code fence content not escaped",
+			input: "text ```\n{code}\n\n``` more {text}",
+			want:  "text ```\n{code}\n\n``` more \\{text\\}",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := escapeMintlifyProse(tt.input)
+			if got != tt.want {
+				t.Errorf("escapeMintlifyProse(%q):\ngot:  %q\nwant: %q", tt.input, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestSanitizeDescription(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  string
+	}{
+		{
+			name:  "carets to backticks",
+			input: "Use ^foo^ bar",
+			want:  "Use `foo` bar",
+		},
+		{
+			name:  "quotes escaped",
+			input: `Say "hello"`,
+			want:  "Say 'hello'",
+		},
+		{
+			name:  "truncation",
+			input: strings.Repeat("x", 250),
+			want:  strings.Repeat("x", 197) + "...",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := sanitizeDescription(tt.input)
+			if got != tt.want {
+				t.Errorf("sanitizeDescription(%q) = %q, want %q", tt.input, got, tt.want)
+			}
+		})
+	}
+}