From bcab22dd9a1a775e2dd8b1b21d30c26160bfd8f1 Mon Sep 17 00:00:00 2001 From: Alex Leventer Date: Sun, 18 Oct 2026 21:29:59 -0700 Subject: [PATCH] Add a meta description to generated flyctl docs pages Mintlify only emits from a page's description frontmatter. The generator writes title and source_url only, so all ~300 flyctl command reference pages on docs.fly.io have no meta description and search engines write their own snippet from the page body. Build one from the command's Short help, e.g. "Reference for fly apps move: move an app to another organization. Usage and flags.", capped at 155 characters. It goes after source_url so the sidebarTitle that generate_docs.sh carries across still lands right after title. Co-Authored-By: Claude Opus 5.5 --- doc/main.go | 94 ++++++++++++++++++++++++++++++++++++++++++++++-- doc/main_test.go | 83 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 doc/main_test.go 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) +}