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