cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1//go:build !prod
2
3package tools
4
5import (
6 "encoding/json"
7 "fmt"
8 "os"
9 "path/filepath"
10 "slices"
11 "strings"
12
13 "github.com/spf13/cobra"
14 "github.com/spf13/cobra/doc"
15)
16
17// NewDocGenCommand creates a hidden command for generating CLI documentation
18func NewDocGenCommand(root *cobra.Command) *cobra.Command {
19 cmd := &cobra.Command{
20 Use: "docgen",
21 Short: "Generate CLI documentation",
22 Hidden: true,
23 RunE: func(cmd *cobra.Command, args []string) error {
24 out, _ := cmd.Flags().GetString("out")
25 format, _ := cmd.Flags().GetString("format")
26 front, _ := cmd.Flags().GetBool("frontmatter")
27
28 if err := os.MkdirAll(out, 0o755); err != nil {
29 return fmt.Errorf("failed to create output directory: %w", err)
30 }
31
32 root.DisableAutoGenTag = true
33
34 switch format {
35 case "docusaurus":
36 if err := generateDocusaurusDocs(root, out); err != nil {
37 return fmt.Errorf("failed to generate docusaurus documentation: %w", err)
38 }
39 case "markdown":
40 if front {
41 prep := func(filename string) string {
42 base := filepath.Base(filename)
43 name := strings.TrimSuffix(base, filepath.Ext(base))
44 title := strings.ReplaceAll(name, "_", " ")
45 return fmt.Sprintf("---\ntitle: %q\nslug: %q\ndescription: \"CLI reference for %s\"\n---\n\n", title, name, title)
46 }
47 link := func(name string) string { return strings.ToLower(name) }
48 if err := doc.GenMarkdownTreeCustom(root, out, prep, link); err != nil {
49 return fmt.Errorf("failed to generate markdown documentation: %w", err)
50 }
51 } else {
52 if err := doc.GenMarkdownTree(root, out); err != nil {
53 return fmt.Errorf("failed to generate markdown documentation: %w", err)
54 }
55 }
56 case "man":
57 hdr := &doc.GenManHeader{Title: strings.ToUpper(root.Name()), Section: "1"}
58 if err := doc.GenManTree(root, hdr, out); err != nil {
59 return fmt.Errorf("failed to generate man pages: %w", err)
60 }
61 case "rest":
62 if err := doc.GenReSTTree(root, out); err != nil {
63 return fmt.Errorf("failed to generate ReStructuredText documentation: %w", err)
64 }
65 default:
66 return fmt.Errorf("unknown format: %s", format)
67 }
68
69 fmt.Fprintf(cmd.OutOrStdout(), "Documentation generated in %s\n", out)
70 return nil
71 },
72 }
73
74 cmd.Flags().StringP("out", "o", "./docs/cli", "output directory")
75 cmd.Flags().StringP("format", "f", "markdown", "output format (docusaurus|markdown|man|rest)")
76 cmd.Flags().Bool("frontmatter", false, "prepend simple YAML front matter to markdown")
77
78 return cmd
79}
80
81// CategoryJSON represents the _category_.json structure for Docusaurus
82type CategoryJSON struct {
83 Label string `json:"label"`
84 Position int `json:"position"`
85 Link *Link `json:"link,omitempty"`
86 Collapsed bool `json:"collapsed,omitempty"`
87 Description string `json:"description,omitempty"`
88}
89
90// Link represents a link in _category_.json
91type Link struct {
92 Type string `json:"type"`
93 Description string `json:"description,omitempty"`
94}
95
96// generateDocusaurusDocs creates combined, Docusaurus-compatible documentation
97func generateDocusaurusDocs(root *cobra.Command, outDir string) error {
98 if err := os.MkdirAll(outDir, 0o755); err != nil {
99 return fmt.Errorf("failed to create output directory: %w", err)
100 }
101
102 category := CategoryJSON{
103 Label: "CLI Reference",
104 Position: 3,
105 Link: &Link{
106 Type: "generated-index",
107 Description: "Complete command-line reference for noteleaf",
108 },
109 }
110 categoryJSON, err := json.MarshalIndent(category, "", " ")
111 if err != nil {
112 return fmt.Errorf("failed to marshal category json: %w", err)
113 }
114 if err := os.WriteFile(filepath.Join(outDir, "_category_.json"), categoryJSON, 0o644); err != nil {
115 return fmt.Errorf("failed to write category json: %w", err)
116 }
117
118 indexContent := generateIndexPage(root)
119 if err := os.WriteFile(filepath.Join(outDir, "index.md"), []byte(indexContent), 0o644); err != nil {
120 return fmt.Errorf("failed to write index.md: %w", err)
121 }
122
123 commandGroups := map[string]struct {
124 title string
125 position int
126 commands []string
127 description string
128 }{
129 "tasks": {
130 title: "Task Management",
131 position: 1,
132 commands: []string{"todo", "task"},
133 description: "Manage tasks with TaskWarrior-inspired features",
134 },
135 "notes": {
136 title: "Notes",
137 position: 2,
138 commands: []string{"note"},
139 description: "Create and organize markdown notes",
140 },
141 "articles": {
142 title: "Articles",
143 position: 3,
144 commands: []string{"article"},
145 description: "Save and archive web articles",
146 },
147 "books": {
148 title: "Books",
149 position: 4,
150 commands: []string{"media book"},
151 description: "Manage reading list and track progress",
152 },
153 "movies": {
154 title: "Movies",
155 position: 5,
156 commands: []string{"media movie"},
157 description: "Track movies in watch queue",
158 },
159 "tv-shows": {
160 title: "TV Shows",
161 position: 6,
162 commands: []string{"media tv"},
163 description: "Manage TV show watching",
164 },
165 "configuration": {
166 title: "Configuration",
167 position: 7,
168 commands: []string{"config"},
169 description: "Manage application configuration",
170 },
171 "management": {
172 title: "Management",
173 position: 8,
174 commands: []string{"status", "setup", "reset"},
175 description: "Application management commands",
176 },
177 }
178
179 for filename, group := range commandGroups {
180 content := generateCombinedPage(root, group.title, group.position, group.commands, group.description)
181 outputFile := filepath.Join(outDir, filename+".md")
182 if err := os.WriteFile(outputFile, []byte(content), 0o644); err != nil {
183 return fmt.Errorf("failed to write %s: %w", outputFile, err)
184 }
185 }
186
187 return nil
188}
189
190// generateIndexPage creates the index/overview page
191func generateIndexPage(root *cobra.Command) string {
192 var b strings.Builder
193
194 b.WriteString("---\n")
195 b.WriteString("id: index\n")
196 b.WriteString("title: CLI Reference\n")
197 b.WriteString("sidebar_label: Overview\n")
198 b.WriteString("sidebar_position: 0\n")
199 b.WriteString("description: Complete command-line reference for noteleaf\n")
200 b.WriteString("---\n\n")
201
202 b.WriteString("# noteleaf CLI Reference\n\n")
203
204 if root.Long != "" {
205 b.WriteString(root.Long)
206 b.WriteString("\n\n")
207 } else if root.Short != "" {
208 b.WriteString(root.Short)
209 b.WriteString("\n\n")
210 }
211
212 b.WriteString("## Usage\n\n")
213 b.WriteString("```bash\n")
214 b.WriteString(root.UseLine())
215 b.WriteString("\n```\n\n")
216
217 b.WriteString("## Command Groups\n\n")
218 b.WriteString("- **[Task Management](tasks)** - Manage todos, projects, and time tracking\n")
219 b.WriteString("- **[Notes](notes)** - Create and organize markdown notes\n")
220 b.WriteString("- **[Articles](articles)** - Save and archive web articles\n")
221 b.WriteString("- **[Books](books)** - Track reading list and progress\n")
222 b.WriteString("- **[Movies](movies)** - Manage movie watch queue\n")
223 b.WriteString("- **[TV Shows](tv-shows)** - Track TV show watching\n")
224 b.WriteString("- **[Configuration](configuration)** - Manage settings\n")
225 b.WriteString("- **[Management](management)** - Application management\n\n")
226
227 return b.String()
228}
229
230// generateCombinedPage creates a combined documentation page for a command group
231func generateCombinedPage(root *cobra.Command, title string, position int, commandPaths []string, description string) string {
232 var b strings.Builder
233
234 slug := strings.ToLower(strings.ReplaceAll(title, " ", "-"))
235 b.WriteString("---\n")
236 b.WriteString(fmt.Sprintf("id: %s\n", slug))
237 b.WriteString(fmt.Sprintf("title: %s\n", title))
238 b.WriteString(fmt.Sprintf("sidebar_position: %d\n", position))
239 b.WriteString(fmt.Sprintf("description: %s\n", description))
240 b.WriteString("---\n\n")
241
242 for _, cmdPath := range commandPaths {
243 cmd := findCommand(root, strings.Split(cmdPath, " "))
244 if cmd == nil {
245 continue
246 }
247
248 b.WriteString(fmt.Sprintf("## %s\n\n", cmd.Name()))
249 if cmd.Long != "" {
250 b.WriteString(cmd.Long)
251 b.WriteString("\n\n")
252 } else if cmd.Short != "" {
253 b.WriteString(cmd.Short)
254 b.WriteString("\n\n")
255 }
256
257 b.WriteString("```bash\n")
258 b.WriteString(cmd.UseLine())
259 b.WriteString("\n```\n\n")
260
261 if cmd.HasSubCommands() {
262 b.WriteString("### Subcommands\n\n")
263 for _, sub := range cmd.Commands() {
264 if sub.Hidden {
265 continue
266 }
267 generateSubcommandSection(&b, sub, 4)
268 }
269 }
270
271 if cmd.HasFlags() {
272 b.WriteString("### Options\n\n")
273 b.WriteString("```\n")
274 b.WriteString(cmd.Flags().FlagUsages())
275 b.WriteString("```\n\n")
276 }
277 }
278
279 return b.String()
280}
281
282// generateSubcommandSection generates documentation for a subcommand
283func generateSubcommandSection(b *strings.Builder, cmd *cobra.Command, level int) {
284 prefix := strings.Repeat("#", level)
285
286 fmt.Fprintf(b, "%s %s\n\n", prefix, cmd.Name())
287
288 if cmd.Long != "" {
289 b.WriteString(cmd.Long)
290 b.WriteString("\n\n")
291 } else if cmd.Short != "" {
292 b.WriteString(cmd.Short)
293 b.WriteString("\n\n")
294 }
295
296 b.WriteString("**Usage:**\n\n")
297 b.WriteString("```bash\n")
298 b.WriteString(cmd.UseLine())
299 b.WriteString("\n```\n\n")
300
301 if cmd.HasLocalFlags() {
302 b.WriteString("**Options:**\n\n")
303 b.WriteString("```\n")
304 b.WriteString(cmd.LocalFlags().FlagUsages())
305 b.WriteString("```\n\n")
306 }
307
308 if len(cmd.Aliases) > 0 {
309 fmt.Fprintf(b, "**Aliases:** %s\n\n", strings.Join(cmd.Aliases, ", "))
310 }
311
312 if cmd.HasSubCommands() {
313 for _, sub := range cmd.Commands() {
314 if sub.Hidden {
315 continue
316 }
317 generateSubcommandSection(b, sub, level+1)
318 }
319 }
320}
321
322// findCommand finds a command by path
323func findCommand(root *cobra.Command, path []string) *cobra.Command {
324 if len(path) == 0 {
325 return root
326 }
327
328 for _, cmd := range root.Commands() {
329 if cmd.Name() == path[0] || slices.Contains(cmd.Aliases, path[0]) {
330 if len(path) == 1 {
331 return cmd
332 }
333 return findCommand(cmd, path[1:])
334 }
335 }
336
337 return nil
338}