diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-07-07 16:24:28 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-07-07 16:24:28 +0200 |
| commit | 37f156c30e2a2b01c840d39f0dd2bdbf811732d6 (patch) | |
| tree | e2ce84dc7f88ce0eb312899448f91b7bb93ca8a3 /internal/cli/cli.go | |
| download | gemcms-main.tar.gz gemcms-main.tar.xz gemcms-main.zip | |
Diffstat (limited to 'internal/cli/cli.go')
| -rw-r--r-- | internal/cli/cli.go | 497 |
1 files changed, 497 insertions, 0 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..4df575c --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,497 @@ +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "strings" + + "gemcms/internal/cms" +) + +const usage = `gemcms is a CLI CMS for Gemini capsules. + +Usage: + gemcms add <file-or-directory> [--type article|page] [--section name] [--tags a,b] [--draft] [--menu name] + gemcms create capsule <name> [--title title] [--host host] [--import path] [--section name] [--tags a,b] [--menu name] [--build] + gemcms create site <name> [--title title] [--host host] + gemcms create page <slug> [--title title] + gemcms create article <title> [--section name] [--tags a,b] [--draft] + gemcms create section <name> [--title title] + gemcms create menu <name> + gemcms create widget <type> [--name name] [--section name] [--limit n] + gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url] + gemcms list <pages|articles|menus|widgets> + gemcms status + gemcms health + gemcms build + gemcms check +` + +func Run(ctx context.Context, args []string, out, errOut io.Writer) error { + _ = ctx + + if len(args) == 0 { + fmt.Fprint(out, usage) + return nil + } + + switch args[0] { + case "help", "-h", "--help": + fmt.Fprint(out, usage) + return nil + case "add": + return runAdd(args[1:], out, errOut) + case "create": + return runCreate(args[1:], out, errOut) + case "menu": + return runMenu(args[1:], out, errOut) + case "list": + return runList(args[1:], out) + case "status": + return runStatus(args[1:], out, false) + case "health": + return runStatus(args[1:], out, true) + case "build": + root, err := cms.FindRoot(".") + if err != nil { + return err + } + report, err := cms.Build(root) + if err != nil { + return err + } + fmt.Fprintf(out, "built %d files into %s\n", report.FilesWritten, report.OutputDir) + return nil + case "check": + root, err := cms.FindRoot(".") + if err != nil { + return err + } + report, err := cms.Check(root) + if err != nil { + return err + } + for _, issue := range report.Issues { + fmt.Fprintf(out, "%s: %s: %s\n", issue.Level, issue.Path, issue.Message) + } + if report.HasErrors() { + return errors.New("check failed") + } + if len(report.Issues) == 0 { + fmt.Fprintln(out, "OK") + } + return nil + default: + return fmt.Errorf("unknown command %q\n\n%s", args[0], usage) + } +} + +func runAdd(args []string, out, errOut io.Writer) error { + fs := newFlagSet("add", errOut) + docType := fs.String("type", "", "content type: article or page") + title := fs.String("title", "", "content title") + slug := fs.String("slug", "", "content slug") + section := fs.String("section", "articles", "article section") + tags := fs.String("tags", "", "comma-separated tags") + draft := fs.Bool("draft", false, "mark content as draft") + menu := fs.String("menu", "", "menu to add the content to") + positionals, err := parseInterspersed(fs, args) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("usage: gemcms add <file-or-directory> [--type article|page] [--section name] [--tags a,b] [--draft] [--menu name]") + } + root, err := cms.FindRoot(".") + if err != nil { + return err + } + report, err := cms.AddPath(root, cms.AddOptions{ + Path: positionals[0], + Type: *docType, + Title: *title, + Slug: *slug, + Section: *section, + Tags: splitCSV(*tags), + Draft: *draft, + Menu: *menu, + }) + if err != nil { + return err + } + for _, doc := range report.Documents { + fmt.Fprintf(out, "added %s %s\n", doc.Type, doc.SourcePath) + } + return nil +} + +func runCreate(args []string, out, errOut io.Writer) error { + if len(args) == 0 { + return fmt.Errorf("missing create target\n\n%s", usage) + } + + switch args[0] { + case "site", "capsule": + fs := newFlagSet("create site", errOut) + title := fs.String("title", "", "capsule title") + host := fs.String("host", "", "Gemini host") + author := fs.String("author", "", "default author") + language := fs.String("language", "en", "content language") + importPath := fs.String("import", "", "file or directory to import after creating the capsule") + importType := fs.String("type", "", "imported content type") + section := fs.String("section", "articles", "section for imported articles") + tags := fs.String("tags", "", "comma-separated tags for imported content") + draft := fs.Bool("draft", false, "mark imported content as draft") + menu := fs.String("menu", "", "menu to add imported content to") + build := fs.Bool("build", false, "build public output after creation") + positionals, err := parseInterspersed(fs, args[1:]) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("usage: gemcms create site <name> [--title title] [--host host]") + } + root, err := cms.CreateSite(".", cms.SiteOptions{ + Name: positionals[0], + Title: *title, + Host: *host, + Author: *author, + Language: *language, + }) + if err != nil { + return err + } + fmt.Fprintf(out, "created site %s\n", root) + if *importPath != "" { + if *section != "articles" { + if _, err := cms.CreateSection(root, cms.SectionOptions{Name: *section}); err != nil { + return err + } + if _, err := cms.UpsertWidget(root, cms.WidgetOptions{Name: "recent-articles", Type: "recent", Section: *section, Limit: 5}); err != nil { + return err + } + } + report, err := cms.AddPath(root, cms.AddOptions{ + Path: *importPath, + Type: *importType, + Section: *section, + Tags: splitCSV(*tags), + Draft: *draft, + Menu: *menu, + }) + if err != nil { + return err + } + for _, doc := range report.Documents { + fmt.Fprintf(out, "added %s %s\n", doc.Type, doc.SourcePath) + } + } + if *build { + report, err := cms.Build(root) + if err != nil { + return err + } + fmt.Fprintf(out, "built %d files into %s\n", report.FilesWritten, report.OutputDir) + } + return nil + case "page": + fs := newFlagSet("create page", errOut) + title := fs.String("title", "", "page title") + menu := fs.String("menu", "", "menu to add the page to") + positionals, err := parseInterspersed(fs, args[1:]) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("usage: gemcms create page <slug> [--title title]") + } + root, err := cms.FindRoot(".") + if err != nil { + return err + } + doc, err := cms.CreatePage(root, cms.PageOptions{ + Slug: positionals[0], + Title: *title, + Menu: *menu, + }) + if err != nil { + return err + } + fmt.Fprintf(out, "created page %s\n", doc.SourcePath) + return nil + case "article": + fs := newFlagSet("create article", errOut) + section := fs.String("section", "articles", "article section") + tags := fs.String("tags", "", "comma-separated tags") + draft := fs.Bool("draft", false, "mark article as draft") + menu := fs.String("menu", "", "menu to add the article to") + positionals, err := parseInterspersed(fs, args[1:]) + if err != nil { + return err + } + if len(positionals) < 1 { + return errors.New("usage: gemcms create article <title> [--section name] [--tags a,b] [--draft]") + } + root, err := cms.FindRoot(".") + if err != nil { + return err + } + doc, err := cms.CreateArticle(root, cms.ArticleOptions{ + Title: strings.Join(positionals, " "), + Section: *section, + Tags: splitCSV(*tags), + Draft: *draft, + Menu: *menu, + }) + if err != nil { + return err + } + fmt.Fprintf(out, "created article %s\n", doc.SourcePath) + return nil + case "section": + fs := newFlagSet("create section", errOut) + title := fs.String("title", "", "section title") + positionals, err := parseInterspersed(fs, args[1:]) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("usage: gemcms create section <name> [--title title]") + } + root, err := cms.FindRoot(".") + if err != nil { + return err + } + path, err := cms.CreateSection(root, cms.SectionOptions{Name: positionals[0], Title: *title}) + if err != nil { + return err + } + fmt.Fprintf(out, "created section %s\n", path) + return nil + case "menu": + fs := newFlagSet("create menu", errOut) + positionals, err := parseInterspersed(fs, args[1:]) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("usage: gemcms create menu <name>") + } + root, err := cms.FindRoot(".") + if err != nil { + return err + } + path, err := cms.CreateMenu(root, positionals[0]) + if err != nil { + return err + } + fmt.Fprintf(out, "created menu %s\n", path) + return nil + case "widget": + fs := newFlagSet("create widget", errOut) + name := fs.String("name", "", "widget config name") + section := fs.String("section", "articles", "section for recent widgets") + limit := fs.Int("limit", 5, "item limit") + positionals, err := parseInterspersed(fs, args[1:]) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("usage: gemcms create widget <type> [--name name] [--section name] [--limit n]") + } + root, err := cms.FindRoot(".") + if err != nil { + return err + } + path, err := cms.CreateWidget(root, cms.WidgetOptions{ + Type: positionals[0], + Name: *name, + Section: *section, + Limit: *limit, + }) + if err != nil { + return err + } + fmt.Fprintf(out, "created widget %s\n", path) + return nil + default: + return fmt.Errorf("unknown create target %q", args[0]) + } +} + +func runMenu(args []string, out, errOut io.Writer) error { + if len(args) == 0 || args[0] != "add" { + return errors.New("usage: gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url]") + } + if len(args) < 3 { + return errors.New("usage: gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url]") + } + + menuName := args[1] + kind := args[2] + target := "" + flagStart := 3 + if requiresTarget(kind) { + if len(args) < 4 || strings.HasPrefix(args[3], "-") { + return fmt.Errorf("menu item %q requires a target", kind) + } + target = args[3] + flagStart = 4 + } + + fs := newFlagSet("menu add", errOut) + label := fs.String("label", "", "menu item label") + url := fs.String("url", "", "menu item URL") + positionals, err := parseInterspersed(fs, args[flagStart:]) + if err != nil { + return err + } + if len(positionals) != 0 { + return fmt.Errorf("unexpected argument %q", positionals[0]) + } + + root, err := cms.FindRoot(".") + if err != nil { + return err + } + if err := cms.AddMenuItem(root, cms.MenuItemOptions{ + Menu: menuName, + Kind: kind, + Target: target, + Label: *label, + URL: *url, + }); err != nil { + return err + } + fmt.Fprintf(out, "added item to menu %s\n", menuName) + return nil +} + +func runList(args []string, out io.Writer) error { + if len(args) != 1 { + return errors.New("usage: gemcms list <pages|articles|menus|widgets>") + } + root, err := cms.FindRoot(".") + if err != nil { + return err + } + items, err := cms.List(root, args[0]) + if err != nil { + return err + } + for _, item := range items { + fmt.Fprintln(out, item) + } + return nil +} + +func runStatus(args []string, out io.Writer, healthOnly bool) error { + if len(args) != 0 { + if healthOnly { + return errors.New("usage: gemcms health") + } + return errors.New("usage: gemcms status") + } + root, err := cms.FindRoot(".") + if err != nil { + return err + } + report, err := cms.Status(root) + if err != nil { + return err + } + if healthOnly { + fmt.Fprintf(out, "%s (%d errors, %d warnings)\n", report.Health(), report.ErrorCount(), report.WarningCount()) + for _, issue := range report.Issues { + fmt.Fprintf(out, "%s: %s: %s\n", issue.Level, issue.Path, issue.Message) + } + if report.ErrorCount() > 0 { + return errors.New("health check failed") + } + return nil + } + + fmt.Fprintf(out, "root: %s\n", report.Root) + fmt.Fprintf(out, "title: %s\n", report.Config.Title) + fmt.Fprintf(out, "host: %s\n", report.Config.Host) + fmt.Fprintf(out, "content: %d pages, %d articles, %d drafts\n", report.Pages, report.Articles, report.Drafts) + fmt.Fprintf(out, "site: %d menus, %d sections, %d widgets\n", report.Menus, report.Sections, report.Widgets) + fmt.Fprintf(out, "public: %d files\n", report.PublicFiles) + fmt.Fprintf(out, "health: %s (%d errors, %d warnings)\n", report.Health(), report.ErrorCount(), report.WarningCount()) + return nil +} + +func newFlagSet(name string, errOut io.Writer) *flag.FlagSet { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(errOut) + return fs +} + +func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) { + var flagArgs []string + var positionals []string + + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "-") || arg == "-" { + positionals = append(positionals, arg) + continue + } + flagArgs = append(flagArgs, arg) + name := strings.TrimLeft(arg, "-") + if idx := strings.Index(name, "="); idx >= 0 { + name = name[:idx] + } + f := fs.Lookup(name) + if f == nil { + return nil, fmt.Errorf("unknown flag %s", arg) + } + if isBoolFlag(f) || strings.Contains(arg, "=") { + continue + } + if i+1 >= len(args) { + return nil, fmt.Errorf("flag %s requires a value", arg) + } + i++ + flagArgs = append(flagArgs, args[i]) + } + + if err := fs.Parse(flagArgs); err != nil { + return nil, err + } + return positionals, nil +} + +func isBoolFlag(f *flag.Flag) bool { + type boolFlag interface { + IsBoolFlag() bool + } + value, ok := f.Value.(boolFlag) + return ok && value.IsBoolFlag() +} + +func splitCSV(value string) []string { + if value == "" { + return nil + } + var out []string + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func requiresTarget(kind string) bool { + switch kind { + case "page", "section", "article", "custom": + return true + default: + return false + } +} |
