Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 92 additions & 2 deletions doc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -70,9 +71,12 @@ func main() {
// emitted: on 36 pages it is a hand-written nav label that cobra's Short
// does not match, so scripts/publish_docs.sh carries the existing value
// through instead of this overwriting it.
//
// description is emitted after source_url so the sidebarTitle carried
// across by scripts/generate_docs.sh still lands on line 3.
filePrepender := func(cmd *cobra.Command, filename string) string {
return fmt.Sprintf("---\ntitle: %s\nsource_url: https://fly.io/docs/flyctl/%s\n---\n\n",
cmd.CommandPath(), legacySlug(filename))
return fmt.Sprintf("---\ntitle: %s\nsource_url: https://fly.io/docs/flyctl/%s\ndescription: %s\n---\n\n",
cmd.CommandPath(), legacySlug(filename), quoteYAML(description(cmd)))
}

// Pages live at /flyctl/cmd/<file basename> on docs.fly.io, so the link is
Expand All @@ -89,6 +93,92 @@ func main() {
}
}

// quoteYAML double-quotes s for frontmatter. Go's escapes are a subset of
// what YAML double-quoted scalars accept, and quoting is needed anyway since
// every description contains ": ".
func quoteYAML(s string) string {
return strconv.Quote(s)
}

// maxDescriptionLen keeps the meta description inside what Google shows in a
// result snippet. Without a description Mintlify emits no meta description at
// all and search engines write their own snippet from the page body.
const maxDescriptionLen = 155

// description builds the page's meta description from the command's Short
// help, e.g. "Reference for fly apps move: move an app to another
// organization. Usage and flags." Each fallback drops the least useful part
// until it fits: the trailing section list, then the "Reference for" lead-in,
// then the end of Short itself.
func description(cmd *cobra.Command) string {
path := cmd.CommandPath()
short := strings.TrimRight(strings.Join(strings.Fields(cmd.Short), " "), ".")
if short == "" {
return truncateWords("Reference for "+path+": usage and flags.", maxDescriptionLen)
}
short = lowerFirstWord(short)

var sections string
switch {
case hasSubCommands(cmd) && len(cmd.Example) > 0:
sections = "Subcommands, usage, flags and examples."
case hasSubCommands(cmd):
sections = "Subcommands, usage and flags."
case len(cmd.Example) > 0:
sections = "Usage, flags and examples."
default:
sections = "Usage and flags."
}

for _, d := range []string{
"Reference for " + path + ": " + short + ". " + sections,
"Reference for " + path + ": " + short + ".",
path + ": " + short + ".",
} {
if len(d) <= maxDescriptionLen {
return d
}
}

return truncateWords(path+": "+short, maxDescriptionLen)
}

// lowerFirstWord lowercases a leading plain capitalized word ("Move an app"
// becomes "move an app") so Short reads naturally after a colon. Acronyms,
// "[experimental]" and product names are left alone.
func lowerFirstWord(s string) string {
word, _, _ := strings.Cut(s, " ")
word = strings.TrimRight(word, ",")
switch word {
case "Fly", "Unmanaged":
return s
}
if len(word) < 2 || word[0] < 'A' || word[0] > 'Z' {
return s
}
for _, r := range word[1:] {
if r < 'a' || r > 'z' {
return s
}
}

return strings.ToLower(s[:1]) + s[1:]
}

// truncateWords cuts s at a word boundary so that it plus an ellipsis fits in
// limit bytes.
func truncateWords(s string, limit int) string {
if len(s) <= limit {
return s
}
cut := s[:limit-3]
if i := strings.LastIndex(cut, " "); i > 0 {
cut = cut[:i]
}

return strings.TrimRight(cut, " ,.:;") + "..."
}

func printOptions(buf *bytes.Buffer, cmd *cobra.Command, name string) error {
flags := cmd.NonInheritedFlags()
flags.SetOutput(buf)
Expand Down
83 changes: 83 additions & 0 deletions doc/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package main

import (
"strings"
"testing"

"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/superfly/flyctl/internal/cli"
)

func TestDescription(t *testing.T) {
root := &cobra.Command{Use: "fly", Short: "The Fly.io command line interface"}
apps := &cobra.Command{Use: "apps", Short: "Manage apps."}
move := &cobra.Command{Use: "move", Short: "Move an app to another organization", Run: func(*cobra.Command, []string) {}}
ssh := &cobra.Command{Use: "ssh", Short: "SSH into a Machine", Example: "fly ssh", Run: func(*cobra.Command, []string) {}}
pg := &cobra.Command{Use: "postgres", Short: "Unmanaged Postgres cluster commands"}
long := &cobra.Command{Use: "long", Short: strings.Repeat("word ", 60), Run: func(*cobra.Command, []string) {}}
root.AddCommand(apps, ssh, pg, long)
apps.AddCommand(move)
pg.AddCommand(&cobra.Command{Use: "list", Short: "list clusters", Run: func(*cobra.Command, []string) {}})

for _, tc := range []struct {
cmd *cobra.Command
want string
}{
{move, "Reference for fly apps move: move an app to another organization. Usage and flags."},
{apps, "Reference for fly apps: manage apps. Subcommands, usage and flags."},
{ssh, "Reference for fly ssh: SSH into a Machine. Usage, flags and examples."},
{pg, "Reference for fly postgres: Unmanaged Postgres cluster commands. Subcommands, usage and flags."},
{root, "Reference for fly: the Fly.io command line interface. Subcommands, usage and flags."},
} {
if got := description(tc.cmd); got != tc.want {
t.Errorf("description(%q)\n got %q\nwant %q", tc.cmd.CommandPath(), got, tc.want)
}
}

if got := description(long); len(got) > maxDescriptionLen || !strings.HasPrefix(got, "fly long: ") || !strings.HasSuffix(got, "...") {
t.Errorf("long Short not truncated cleanly: %q (%d)", got, len(got))
}
}

// Every generated page must get a description that fits in a search snippet,
// names its command, and survives as YAML frontmatter.
func TestDescriptionAllCommands(t *testing.T) {
root := cli.NewRootCommand()
root.Use = "fly"

seen := map[string]string{}
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
if c != root && (!c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand()) {
return
}
d := description(c)
if len(d) > maxDescriptionLen {
t.Errorf("%s: description is %d bytes: %q", c.CommandPath(), len(d), d)
}
if !strings.Contains(d, c.CommandPath()+":") {
t.Errorf("%s: description does not name the command: %q", c.CommandPath(), d)
}

var fm struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
}
block := "title: " + c.CommandPath() + "\ndescription: " + quoteYAML(d) + "\n"
if err := yaml.Unmarshal([]byte(block), &fm); err != nil || fm.Description != d {
t.Errorf("%s: frontmatter does not round-trip: %v %q", c.CommandPath(), err, fm.Description)
}

if other, ok := seen[d]; ok {
t.Logf("%s and %s share a description: %q", c.CommandPath(), other, d)
}
seen[d] = c.CommandPath()

for _, child := range c.Commands() {
walk(child)
}
}
walk(root)
}
Loading