From b3594be86ca601b4298784501c1e94890a255522 Mon Sep 17 00:00:00 2001 From: Facundo Olano Date: Sat, 24 Feb 2024 12:39:45 -0300 Subject: [PATCH] Refactor CLI using kong (#13) * use kong for cli parsing * fix kong usage * remove conditional * don't blow up serve if src dir is missing * load config in main side (boilerplaty) * fix weird names * add versions and aliases * fix version printing * replace command switch with Run methods * move subcommand structs to commands package * distribute commands into files * add usage to docs * add flags to configure server --- commands/commands.go | 181 +---------------------------- commands/init.go | 117 +++++++++++++++++++ commands/post.go | 91 +++++++++++++++ commands/serve.go | 18 ++- config/config.go | 8 +- docs/src/tutorial/installation.org | 26 ++++- go.mod | 1 + go.sum | 2 + main.go | 70 +++-------- site/site.go | 9 +- 10 files changed, 279 insertions(+), 244 deletions(-) create mode 100644 commands/init.go create mode 100644 commands/post.go diff --git a/commands/commands.go b/commands/commands.go index 8a1ac84..d4fae62 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -3,158 +3,23 @@ package commands import ( "bufio" "fmt" - "io" - "io/fs" "os" - "path/filepath" - "regexp" "strings" - "time" - - "embed" + "github.com/alecthomas/kong" "github.com/facundoolano/jorge/config" "github.com/facundoolano/jorge/site" - "golang.org/x/text/unicode/norm" ) -//go:embed all:initfiles -var initfiles embed.FS - -var INIT_CONFIG string = `name: "%s" -author: "%s" -url: "%s" -` -var INIT_README string = ` -# %s - -A jorge blog by %s. -` -var DEFAULT_FRONTMATTER string = `--- -title: %s -date: %s -layout: post -lang: %s -tags: [] ---- -` - -var DEFAULT_ORG_DIRECTIVES string = `#+OPTIONS: toc:nil num:nil -#+LANGUAGE: %s -` - const FILE_RW_MODE = 0777 -// Initialize a new jorge project in the given directory, -// prompting for basic site config and creating default files. -func Init(projectDir string) error { - if err := ensureEmptyProjectDir(projectDir); err != nil { - return err - } - - siteName := Prompt("site name") - siteUrl := Prompt("site url") - siteAuthor := Prompt("author") - fmt.Println() - - // creating config and readme files manually, since I want to use the supplied config values in their - // contents. (I don't want to render liquid templates in the WalkDir below since some of the initfiles - // are actual templates that should be left as is). - configDir := filepath.Join(projectDir, "config.yml") - configFile := fmt.Sprintf(INIT_CONFIG, siteName, siteAuthor, siteUrl) - os.WriteFile(configDir, []byte(configFile), site.FILE_RW_MODE) - fmt.Println("added", configDir) - - readmeDir := filepath.Join(projectDir, "README.md") - readmeFile := fmt.Sprintf(INIT_README, siteName, siteAuthor) - os.WriteFile(readmeDir, []byte(readmeFile), site.FILE_RW_MODE) - fmt.Println("added", readmeDir) - - // walk over initfiles fs - // copy create directories and copy files at target - - initfilesRoot := "initfiles" - return fs.WalkDir(initfiles, initfilesRoot, func(path string, entry fs.DirEntry, err error) error { - if path == initfilesRoot { - return nil - } - subpath, _ := filepath.Rel(initfilesRoot, path) - targetPath := filepath.Join(projectDir, subpath) - - // if it's a directory create it at the same location - if entry.IsDir() { - return os.MkdirAll(targetPath, FILE_RW_MODE) - } - - // TODO duplicated in site, extract to somewhere else - // if its a file, copy it over - targetFile, err := os.Create(targetPath) - if err != nil { - return err - } - defer targetFile.Close() - - source, err := initfiles.Open(path) - if err != nil { - return err - } - defer source.Close() - - _, err = io.Copy(targetFile, source) - if err != nil { - return err - } - fmt.Println("added", targetPath) - return targetFile.Sync() - }) -} - -// Create a new post template in the given site, with the given title, -// with pre-filled front matter. -func Post(root string, title string) error { - config, err := config.Load(root) - if err != nil { - return err - } - - now := time.Now() - slug := slugify(title) - filename := strings.ReplaceAll(config.PostFormat, ":title", slug) - filename = strings.ReplaceAll(filename, ":year", fmt.Sprintf("%d", now.Year())) - filename = strings.ReplaceAll(filename, ":month", fmt.Sprintf("%02d", now.Month())) - filename = strings.ReplaceAll(filename, ":day", fmt.Sprintf("%02d", now.Day())) - path := filepath.Join(config.SrcDir, filename) - - // ensure the dir already exists - if err := os.MkdirAll(filepath.Dir(path), FILE_RW_MODE); err != nil { - return err - } - - // if file already exists, prompt user for a different one - if _, err := os.Stat(path); os.IsExist(err) { - fmt.Printf("%s already exists\n", path) - filename = Prompt("filename") - path = filepath.Join(config.SrcDir, filename) - } - - // initialize the post front matter - content := fmt.Sprintf(DEFAULT_FRONTMATTER, title, now.Format(time.DateTime), config.Lang) - - // org files need some extra boilerplate - if filepath.Ext(path) == ".org" { - content += fmt.Sprintf(DEFAULT_ORG_DIRECTIVES, config.Lang) - } - - if err := os.WriteFile(path, []byte(content), FILE_RW_MODE); err != nil { - return err - } - fmt.Println("added", path) - return nil +type Build struct { + ProjectDir string `arg:"" name:"path" optional:"" default:"." help:"Path to the website project to build."` } // Read the files in src/ render them and copy the result to target/ -func Build(root string) error { - config, err := config.Load(root) +func (cmd *Build) Run(ctx *kong.Context) error { + config, err := config.Load(cmd.ProjectDir) if err != nil { return err } @@ -181,39 +46,3 @@ func Prompt(label string) string { } return strings.TrimSpace(s) } - -func ensureEmptyProjectDir(projectDir string) error { - if err := os.Mkdir(projectDir, 0777); err != nil { - // if it fails with dir already exist, check if it's empty - // https://stackoverflow.com/a/30708914/993769 - if os.IsExist(err) { - // check if empty - dir, err := os.Open(projectDir) - if err != nil { - return err - } - defer dir.Close() - - // if directory is non empty, fail - _, err = dir.Readdirnames(1) - if err == nil { - return fmt.Errorf("non empty directory %s", projectDir) - } - return err - } - } - return nil -} - -var nonWordRegex = regexp.MustCompile(`[^\w-]`) -var whitespaceRegex = regexp.MustCompile(`\s+`) - -func slugify(title string) string { - slug := strings.ToLower(title) - slug = strings.TrimSpace(slug) - slug = norm.NFD.String(slug) - slug = whitespaceRegex.ReplaceAllString(slug, "-") - slug = nonWordRegex.ReplaceAllString(slug, "") - - return slug -} diff --git a/commands/init.go b/commands/init.go new file mode 100644 index 0000000..14699c7 --- /dev/null +++ b/commands/init.go @@ -0,0 +1,117 @@ +package commands + +import ( + "embed" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/alecthomas/kong" + "github.com/facundoolano/jorge/site" +) + +//go:embed all:initfiles +var initfiles embed.FS + +var INIT_CONFIG string = `name: "%s" +author: "%s" +url: "%s" +` +var INIT_README string = ` +# %s + +A jorge blog by %s. +` + +type Init struct { + ProjectDir string `arg:"" name:"path" help:"Directory where to initialize the website project."` +} + +// Initialize a new jorge project in the given directory, +// prompting for basic site config and creating default files. +func (cmd *Init) Run(ctx *kong.Context) error { + if err := ensureEmptyProjectDir(cmd.ProjectDir); err != nil { + return err + } + + siteName := Prompt("site name") + siteUrl := Prompt("site url") + siteAuthor := Prompt("author") + fmt.Println() + + // creating config and readme files manually, since I want to use the supplied config values in their + // contents. (I don't want to render liquid templates in the WalkDir below since some of the initfiles + // are actual templates that should be left as is). + configPath := filepath.Join(cmd.ProjectDir, "config.yml") + configFile := fmt.Sprintf(INIT_CONFIG, siteName, siteAuthor, siteUrl) + os.WriteFile(configPath, []byte(configFile), site.FILE_RW_MODE) + fmt.Println("added", configPath) + + readmePath := filepath.Join(cmd.ProjectDir, "README.md") + readmeFile := fmt.Sprintf(INIT_README, siteName, siteAuthor) + os.WriteFile(readmePath, []byte(readmeFile), site.FILE_RW_MODE) + fmt.Println("added", readmePath) + + // walk over initfiles fs + // copy create directories and copy files at target + + initfilesRoot := "initfiles" + return fs.WalkDir(initfiles, initfilesRoot, func(path string, entry fs.DirEntry, err error) error { + if path == initfilesRoot { + return nil + } + subpath, _ := filepath.Rel(initfilesRoot, path) + targetPath := filepath.Join(cmd.ProjectDir, subpath) + + // if it's a directory create it at the same location + if entry.IsDir() { + return os.MkdirAll(targetPath, FILE_RW_MODE) + } + + // TODO duplicated in site, extract to somewhere else + // if its a file, copy it over + targetFile, err := os.Create(targetPath) + if err != nil { + return err + } + defer targetFile.Close() + + source, err := initfiles.Open(path) + if err != nil { + return err + } + defer source.Close() + + _, err = io.Copy(targetFile, source) + if err != nil { + return err + } + fmt.Println("added", targetPath) + return targetFile.Sync() + }) +} + +func ensureEmptyProjectDir(projectDir string) error { + if err := os.Mkdir(projectDir, 0777); err != nil { + // if it fails with dir already exist, check if it's empty + // https://stackoverflow.com/a/30708914/993769 + if os.IsExist(err) { + // check if empty + dir, err := os.Open(projectDir) + if err != nil { + return err + } + defer dir.Close() + + // if directory is non empty, fail + _, err = dir.Readdirnames(1) + if err == nil { + return fmt.Errorf("non empty directory %s", projectDir) + } + return err + } + } + return nil +} diff --git a/commands/post.go b/commands/post.go new file mode 100644 index 0000000..e8f9d1e --- /dev/null +++ b/commands/post.go @@ -0,0 +1,91 @@ +package commands + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/alecthomas/kong" + "github.com/facundoolano/jorge/config" + "golang.org/x/text/unicode/norm" +) + +var DEFAULT_FRONTMATTER string = `--- +title: %s +date: %s +layout: post +lang: %s +tags: [] +--- +` + +var DEFAULT_ORG_DIRECTIVES string = `#+OPTIONS: toc:nil num:nil +#+LANGUAGE: %s +` + +type Post struct { + Title string `arg:"" optional:"" help:"Title of the post"` +} + +// Create a new post template in the given site, with the given title, +// with pre-filled front matter. +func (cmd *Post) Run(ctx *kong.Context) error { + title := cmd.Title + if title == "" { + title = Prompt("title") + } + config, err := config.Load(".") + if err != nil { + return err + } + now := time.Now() + slug := slugify(title) + filename := strings.ReplaceAll(config.PostFormat, ":title", slug) + + filename = strings.ReplaceAll(filename, ":year", fmt.Sprintf("%d", now.Year())) + filename = strings.ReplaceAll(filename, ":month", fmt.Sprintf("%02d", now.Month())) + filename = strings.ReplaceAll(filename, ":day", fmt.Sprintf("%02d", now.Day())) + path := filepath.Join(config.SrcDir, filename) + + // ensure the dir already exists + if err := os.MkdirAll(filepath.Dir(path), FILE_RW_MODE); err != nil { + return err + } + + // if file already exists, prompt user for a different one + if _, err := os.Stat(path); os.IsExist(err) { + fmt.Printf("%s already exists\n", path) + filename = Prompt("filename") + path = filepath.Join(config.SrcDir, filename) + } + + // initialize the post front matter + content := fmt.Sprintf(DEFAULT_FRONTMATTER, title, now.Format(time.DateTime), config.Lang) + + // org files need some extra boilerplate + if filepath.Ext(path) == ".org" { + content += fmt.Sprintf(DEFAULT_ORG_DIRECTIVES, config.Lang) + } + + if err := os.WriteFile(path, []byte(content), FILE_RW_MODE); err != nil { + return err + } + fmt.Println("added", path) + return nil +} + +var nonWordRegex = regexp.MustCompile(`[^\w-]`) +var whitespaceRegex = regexp.MustCompile(`\s+`) + +func slugify(title string) string { + slug := strings.ToLower(title) + slug = strings.TrimSpace(slug) + slug = norm.NFD.String(slug) + slug = whitespaceRegex.ReplaceAllString(slug, "-") + slug = nonWordRegex.ReplaceAllString(slug, "") + + return slug +} diff --git a/commands/serve.go b/commands/serve.go index 3aa2a75..e6641e1 100644 --- a/commands/serve.go +++ b/commands/serve.go @@ -9,19 +9,29 @@ import ( "sync/atomic" "time" + "github.com/alecthomas/kong" "github.com/facundoolano/jorge/config" "github.com/facundoolano/jorge/site" "github.com/fsnotify/fsnotify" ) -// Generate and serve the site, rebuilding when the source files change -// and triggering a page refresh on clients browsing it. -func Serve(rootDir string) error { - config, err := config.LoadDev(rootDir) +type Serve struct { + ProjectDir string `arg:"" name:"path" optional:"" default:"." help:"Path to the website project to serve."` + Host string `short:"h" default:"localhost" help:"Host to run the server on."` + Port int `short:"p" default:"4001" help:"Port to run the server on."` + NoReload bool `help:"Disable live reloading."` +} + +func (cmd *Serve) Run(ctx *kong.Context) error { + config, err := config.LoadDev(cmd.ProjectDir, cmd.Host, cmd.Port, !cmd.NoReload) if err != nil { return err } + if _, err := os.Stat(config.SrcDir); os.IsNotExist(err) { + return fmt.Errorf("missing src directory") + } + // watch for changes in src and layouts, and trigger a rebuild watcher, broker, err := setupWatcher(config) if err != nil { diff --git a/config/config.go b/config/config.go index 69af09d..17f49bb 100644 --- a/config/config.go +++ b/config/config.go @@ -92,7 +92,7 @@ func Load(rootDir string) (*Config, error) { return config, nil } -func LoadDev(rootDir string) (*Config, error) { +func LoadDev(rootDir string, host string, port int, reload bool) (*Config, error) { // TODO revisit is this Load vs LoadDevServer is the best way to handle both modes // TODO some of the options need to be overridable: host, port, live reload at least @@ -102,10 +102,10 @@ func LoadDev(rootDir string) (*Config, error) { } // setup serve command specific overrides (these could be eventually tweaked with flags) - config.ServerHost = "localhost" - config.ServerPort = 4001 + config.ServerHost = host + config.ServerPort = port + config.LiveReload = reload config.Minify = false - config.LiveReload = true config.LinkStatic = true config.SiteUrl = fmt.Sprintf("http://%s:%d", config.ServerHost, config.ServerPort) diff --git a/docs/src/tutorial/installation.org b/docs/src/tutorial/installation.org index e462c75..f49d810 100755 --- a/docs/src/tutorial/installation.org +++ b/docs/src/tutorial/installation.org @@ -23,7 +23,31 @@ $ go install github.com/facundoolano/jorge@latest #+end_src -TODO: switch to cli and show usage output +Once installed, the help command will provide an overview of what you can do with jorge: + +#+begin_src +$ jorge -h +Usage: jorge + +Commands: + init (i) + Initialize a new website project. + + build (b) [] + Build a website project. + + post (p) [] + Initialize a new post template file. + + serve (s) [<path>] + Run a local server for the website. + +Flags: + -h, --help Show context-sensitive help. + -v, --version + +Run "jorge <command> --help" for more information on a command. +#+end_src #+HTML: <br> #+ATTR_HTML: :align right diff --git a/go.mod b/go.mod index 544faed..b27f655 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( ) require ( + github.com/alecthomas/kong v0.8.1 // indirect github.com/osteele/tuesday v1.0.3 // indirect github.com/tdewolff/parse/v2 v2.7.11 // indirect golang.org/x/sys v0.16.0 // indirect diff --git a/go.sum b/go.sum index c85cb3c..37fdbf5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/alecthomas/kong v0.8.1 h1:acZdn3m4lLRobeh3Zi2S2EpnXTd1mOL6U7xVml+vfkY= +github.com/alecthomas/kong v0.8.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elliotchance/orderedmap/v2 v2.2.0 h1:7/2iwO98kYT4XkOjA9mBEIwvi4KpGB4cyHeOFOnj4Vk= diff --git a/main.go b/main.go index 0ac514d..4f66276 100644 --- a/main.go +++ b/main.go @@ -1,61 +1,25 @@ package main import ( - "errors" - "fmt" - "os" - + "github.com/alecthomas/kong" "github.com/facundoolano/jorge/commands" ) +var cli struct { + Init commands.Init `cmd:"" help:"Initialize a new website project." aliases:"i"` + Build commands.Build `cmd:"" help:"Build a website project." aliases:"b"` + Post commands.Post `cmd:"" help:"Initialize a new post template file." help:"title of the new post." aliases:"p"` + Serve commands.Serve `cmd:"" help:"Run a local server for the website." aliases:"s"` + Version kong.VersionFlag `short:"v"` +} + func main() { - err := run(os.Args) - - if err != nil { - fmt.Println("error:", err) - os.Exit(1) - } -} - -func run(args []string) error { - // TODO consider using cobra or something else to make cli more declarative - // and get a better ux out of the box - - if len(os.Args) < 2 { - // TODO print usage - return errors.New("expected subcommand") - } - - switch os.Args[1] { - case "init": - if len(os.Args) < 3 { - return errors.New("project directory missing") - } - rootDir := os.Args[2] - return commands.Init(rootDir) - case "build": - rootDir := "." - if len(os.Args) > 2 { - rootDir = os.Args[2] - } - return commands.Build(rootDir) - case "post": - var title string - if len(os.Args) >= 3 { - title = os.Args[2] - } else { - title = commands.Prompt("title") - } - rootDir := "." - return commands.Post(rootDir, title) - case "serve": - rootDir := "." - if len(os.Args) > 2 { - rootDir = os.Args[2] - } - return commands.Serve(rootDir) - default: - // TODO print usage - return errors.New("unknown subcommand") - } + ctx := kong.Parse( + &cli, + kong.UsageOnError(), + kong.HelpOptions{FlagsLast: true}, + kong.Vars{"version": "jorge v.0.1.2"}, + ) + err := ctx.Run() + ctx.FatalIfErrorf(err) } diff --git a/site/site.go b/site/site.go index 7ca09f0..5e10668 100644 --- a/site/site.go +++ b/site/site.go @@ -120,14 +120,11 @@ func (site *Site) loadDataFiles() error { } func (site *Site) loadTemplates() error { - _, err := os.ReadDir(site.Config.SrcDir) - if os.IsNotExist(err) { - return fmt.Errorf("missing %s directory", site.Config.SrcDir) - } else if err != nil { - return fmt.Errorf("couldn't read %s", site.Config.SrcDir) + if _, err := os.Stat(site.Config.SrcDir); os.IsNotExist(err) { + return fmt.Errorf("missing src directory") } - err = filepath.WalkDir(site.Config.SrcDir, func(path string, entry fs.DirEntry, err error) error { + err := filepath.WalkDir(site.Config.SrcDir, func(path string, entry fs.DirEntry, err error) error { if !entry.IsDir() { templ, err := templates.Parse(site.templateEngine, path) // if something fails or this is not a template, skip