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
This commit is contained in:
Facundo Olano 2024-02-24 12:39:45 -03:00 committed by GitHub
parent 4bc6867c91
commit b3594be86c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 279 additions and 244 deletions

View file

@ -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
}

117
commands/init.go Normal file
View file

@ -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
}

91
commands/post.go Normal file
View file

@ -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
}

View file

@ -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 {

View file

@ -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)

View file

@ -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 <command>
Commands:
init (i) <path>
Initialize a new website project.
build (b) [<path>]
Build a website project.
post (p) [<title>]
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

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

70
main.go
View file

@ -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)
}

View file

@ -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