diff --git a/doc/main.go b/doc/main.go index 1cad6bcca3..601142c6ba 100644 --- a/doc/main.go +++ b/doc/main.go @@ -12,6 +12,7 @@ import ( "path" "path/filepath" "sort" + "strconv" "strings" "time" @@ -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/ on docs.fly.io, so the link is @@ -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) diff --git a/doc/main_test.go b/doc/main_test.go new file mode 100644 index 0000000000..dfba9ae5ea --- /dev/null +++ b/doc/main_test.go @@ -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) +}