blob: 8b6c278251e764b85f611ccc5c5f1f62c3dab6b6 [file] [log] [blame]
// Licensed to the Apache Software Foundation (ASF) under one or more
// contributor license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright ownership.
// The ASF licenses this file to You under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package appcmd
import (
"bufio"
"bytes"
"context"
"fmt"
"html"
"io"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode"
)
import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"
)
import (
"github.com/apache/dubbo-kubernetes/pkg/bufman/pkg/app"
)
const (
webpagesConfigFlag = "config"
)
var codeBlockRegex = regexp.MustCompile(`(^\s\s\s\s)|(^\t)`)
type webpagesFlags struct {
Config string
}
// webpagesConfig configures the doc generator, example config:
// exclude_commands:
// - buf completion
// - buf ls-files
// weight_commands:
// buf beta: 1
// slug_prefix: /reference/cli/
// output_dir: output/docs
type webpagesConfig struct {
// ExcludeCommands will filter out these command paths from generation.
ExcludeCommands []string `yaml:"exclude_commands,omitempty"`
// WeightCommands will weight the command paths and show higher weighted commands later on the sidebar.
WeightCommands map[string]int `yaml:"weight_commands,omitempty"`
SlugPrefix string `yaml:"slug_prefix,omitempty"`
OutputDir string `yaml:"output_dir,omitempty"`
// SidebarPathThreshold will dictate if the sidebar label is the full path or just the name.
// if the command path is longer than this then the `cobra.Command.Name()` is used,
// otherwise `cobra.Command.CommandPath() is used.
SidebarPathThreshold int `yaml:"sidebar_path_threshold,omitempty"`
}
func newWebpagesFlags() *webpagesFlags {
return &webpagesFlags{}
}
func (f *webpagesFlags) Bind(flagSet *pflag.FlagSet) {
flagSet.StringVar(
&f.Config,
webpagesConfigFlag,
"",
"Config file to use",
)
}
// newWebpagesCommand returns a "webpages" command that generates docusaurus markdown for cobra commands.
// In the future this will need to be adapted to accept a Command when cobra.Command is removed.
func newWebpagesCommand(
command *cobra.Command,
) *Command {
flags := newWebpagesFlags()
return &Command{
Use: "webpages",
Hidden: true,
Run: func(ctx context.Context, container app.Container) error {
cfg, err := readConfig(flags.Config)
if err != nil {
return err
}
excludes := make(map[string]bool)
for _, exclude := range cfg.ExcludeCommands {
excludes[exclude] = true
}
for _, cmd := range command.Commands() {
if excludes[cmd.CommandPath()] {
cmd.Hidden = true
}
}
return generateMarkdownTree(
command,
cfg,
cfg.OutputDir,
)
},
BindFlags: flags.Bind,
}
}
// generateMarkdownTree generates markdown for a whole command tree.
func generateMarkdownTree(cmd *cobra.Command, config webpagesConfig, parentDirPath string) error {
if !cmd.IsAvailableCommand() {
return nil
}
dirPath := parentDirPath
fileName := cmd.Name() + ".md"
if cmd.HasSubCommands() {
dirPath = filepath.Join(parentDirPath, cmd.Name())
if err := os.MkdirAll(dirPath, os.ModePerm); err != nil {
return err
}
fileName = "index.md"
}
filePath := filepath.Join(dirPath, fileName)
f, err := os.Create(filePath)
if err != nil {
return err
}
defer f.Close()
if err := generateMarkdownPage(cmd, config, f); err != nil {
return err
}
if cmd.HasSubCommands() {
commands := cmd.Commands()
orderCommands(config.WeightCommands, commands)
for _, command := range commands {
if err := generateMarkdownTree(command, config, dirPath); err != nil {
return err
}
}
}
return nil
}
// generateMarkdownPage creates custom markdown output.
func generateMarkdownPage(cmd *cobra.Command, config webpagesConfig, w io.Writer) error {
var err error
p := func(format string, a ...any) {
_, err = w.Write([]byte(fmt.Sprintf(format, a...)))
}
p("---\n")
p("id: %s\n", websitePageID(cmd))
p("title: %s\n", cmd.CommandPath())
p("sidebar_label: %s\n", sidebarLabel(cmd, config.SidebarPathThreshold))
p("sidebar_position: %d\n", websiteSidebarPosition(cmd, config.WeightCommands))
p("slug: /%s\n", path.Join(config.SlugPrefix, websiteSlug(cmd)))
p("---\n")
cmd.InitDefaultHelpCmd()
cmd.InitDefaultHelpFlag()
if cmd.Version != "" {
p("version `%s`\n\n", cmd.Version)
}
p(cmd.Short)
p("\n\n")
if cmd.Runnable() {
p("### Usage\n")
p("```terminal\n$ %s\n```\n\n", cmd.UseLine())
}
if len(cmd.Long) > 0 {
p("### Description\n\n")
p("%s \n\n", escapeDescription(cmd.Long))
}
if len(cmd.Example) > 0 {
p("### Examples\n\n")
p("```\n%s\n```\n\n", escapeDescription(cmd.Example))
}
commandFlags := cmd.NonInheritedFlags()
if commandFlags.HasAvailableFlags() {
p("### Flags {#flags}\n\n")
if err := printFlags(commandFlags, w); err != nil {
return err
}
}
inheritedFlags := cmd.InheritedFlags()
if inheritedFlags.HasAvailableFlags() {
p("### Flags inherited from parent commands {#persistent-flags}\n")
if err := printFlags(inheritedFlags, w); err != nil {
return err
}
}
if hasSubCommands(cmd) {
p("### Subcommands\n\n")
children := cmd.Commands()
orderCommands(config.WeightCommands, children)
for _, child := range children {
if !child.IsAvailableCommand() || child.IsAdditionalHelpTopicCommand() {
continue
}
childRelPath := child.Name() + ".md"
if child.HasSubCommands() {
childRelPath = filepath.Join(child.Name(), "index.md")
}
p("* [%s](./%s)\t - %s\n", child.CommandPath(), childRelPath, child.Short)
}
p("\n")
}
if cmd.HasParent() {
p("### Parent Command\n\n")
parent := cmd.Parent()
parentName := parent.CommandPath()
if hasSubCommands(cmd) {
p("* [%s](../index.md)\t - %s\n", parentName, parent.Short)
} else {
p("* [%s](./index.md)\t - %s\n", parentName, parent.Short)
}
cmd.VisitParents(func(c *cobra.Command) {
if c.DisableAutoGenTag {
cmd.DisableAutoGenTag = c.DisableAutoGenTag
}
})
}
return err
}
func websitePageID(cmd *cobra.Command) string {
return strings.ReplaceAll(cmd.CommandPath(), " ", "-")
}
func hasSubCommands(cmd *cobra.Command) bool {
for _, command := range cmd.Commands() {
if !command.IsAvailableCommand() || command.IsAdditionalHelpTopicCommand() {
continue
}
return true
}
return false
}
// escapeDescription is a bit of a hack because docusaurus markdown rendering is a bit weird.
// If the code block is indented then escaping html characters is skipped, otherwise it will
// html.Escape the string.
func escapeDescription(s string) string {
out := &bytes.Buffer{}
read := bufio.NewReader(strings.NewReader(s))
var inCodeBlock bool
for {
line, _, err := read.ReadLine()
if err == io.EOF {
break
}
text := string(line)
// convert indented code blocks into terminal code blocks so the
// $ isn't copied when using the copy button
if codeBlockRegex.MatchString(text) {
if !inCodeBlock {
out.WriteString("```terminal\n")
inCodeBlock = true
}
// remove the indentation level from the indented code block
text = codeBlockRegex.ReplaceAllString(text, "")
out.WriteString(text)
out.WriteString("\n")
continue
}
// indented code blocks can have blank lines in them so
// if the next line is a whitespace then we don't want to
// terminate the code block
if inCodeBlock && text == "" {
if b, err := read.Peek(1); err == nil && unicode.IsSpace(rune(b[0])) {
out.WriteString(text)
out.WriteString("\n")
continue
}
}
// terminate the fenced code block with ```
if inCodeBlock {
out.WriteString("```\n")
inCodeBlock = false
}
out.WriteString(html.EscapeString(text))
out.WriteString("\n")
}
if inCodeBlock {
out.WriteString("```\n")
}
return out.String()
}
func readConfig(filename string) (webpagesConfig, error) {
if filename == "" {
return webpagesConfig{}, nil
}
file, err := os.Open(filename)
if err != nil {
return webpagesConfig{}, err
}
yamlBytes, err := io.ReadAll(file)
if err != nil {
return webpagesConfig{}, err
}
var cfg webpagesConfig
if err := yaml.Unmarshal(yamlBytes, &cfg); err != nil {
return webpagesConfig{}, err
}
return cfg, err
}
func orderCommands(weights map[string]int, commands []*cobra.Command) {
sort.SliceStable(commands, func(i, j int) bool {
return weights[commands[i].CommandPath()] < weights[commands[j].CommandPath()]
})
}
func printFlags(f *pflag.FlagSet, w io.Writer) error {
var err error
p := func(format string, a ...any) {
_, err = w.Write([]byte(fmt.Sprintf(format, a...)))
}
f.VisitAll(func(flag *pflag.Flag) {
if flag.Hidden {
return
}
if flag.Shorthand != "" && flag.ShorthandDeprecated == "" {
p("#### -%s, --%s", flag.Shorthand, flag.Name)
} else {
p("#### --%s", flag.Name)
}
varname, usage := pflag.UnquoteUsage(flag)
if varname != "" {
p(" *%s*", varname)
}
p(" {#%s}", flag.Name)
p("\n")
p(usage)
if flag.NoOptDefVal != "" {
switch flag.Value.Type() {
case "string":
p("[=\"%s\"]", flag.NoOptDefVal)
case "bool":
if flag.NoOptDefVal != "true" {
p("[=%s]", flag.NoOptDefVal)
}
case "count":
if flag.NoOptDefVal != "+1" {
p("[=%s]", flag.NoOptDefVal)
}
default:
p("[=%s]", flag.NoOptDefVal)
}
}
if len(flag.Deprecated) != 0 {
p(" (DEPRECATED: %s)", flag.Deprecated)
}
p("\n\n")
})
return err
}
// websiteSidebarPosition calculates the position of the given command in the website sidebar.
func websiteSidebarPosition(cmd *cobra.Command, weights map[string]int) int {
// Return 0 if the command has no parent
if !cmd.HasParent() {
return 0
}
siblings := cmd.Parent().Commands()
orderCommands(weights, siblings)
position := 0
for _, sibling := range siblings {
if isCommandVisible(sibling) {
position++
if sibling.CommandPath() == cmd.CommandPath() {
return position
}
}
}
return -1
}
// isCommandVisible checks if a command is visible (available, not an additional help topic, and not hidden).
func isCommandVisible(cmd *cobra.Command) bool {
return cmd.IsAvailableCommand() && !cmd.IsAdditionalHelpTopicCommand() && !cmd.Hidden
}
func websiteSlug(cmd *cobra.Command) string {
return strings.ReplaceAll(cmd.CommandPath(), " ", "/")
}
func sidebarLabel(cmd *cobra.Command, maxSidebarLen int) string {
if len(strings.Split(cmd.CommandPath(), " ")) > maxSidebarLen {
return cmd.Name()
}
return cmd.CommandPath()
}