diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fd9d10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +build/ \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..0f68ed0 --- /dev/null +++ b/config/config.go @@ -0,0 +1,22 @@ +package config + +import "path/filepath" + +type Config struct { + DatabaseURL string `env:"DATABASE_URL"` + + DataDir string `env:"DATA_DIR"` + DataStagingDir string `env:"DATA_STAGING_DIR,default=staging"` +} + +func Load() (Config, error) { + return Config{ + DatabaseURL: "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable", + DataDir: "build/data", + DataStagingDir: "staging", + }, nil +} + +func (c Config) StagingDir() string { + return filepath.Join(c.DataDir, c.DataStagingDir) +} diff --git a/main.go b/main.go index ff390d1..36c9071 100644 --- a/main.go +++ b/main.go @@ -2,18 +2,37 @@ package main import ( "context" - "lmika.dev/lmika/hugo-crm/models" + "lmika.dev/lmika/hugo-crm/config" "lmika.dev/lmika/hugo-crm/providers/db" + "lmika.dev/lmika/hugo-crm/providers/git" + "lmika.dev/lmika/hugo-crm/providers/hugo" + "lmika.dev/lmika/hugo-crm/providers/themes" + "lmika.dev/lmika/hugo-crm/services/sites" "log" + "time" ) func main() { - dbp, err := db.New("postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable") + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + + dbp, err := db.New(cfg.DatabaseURL) if err != nil { log.Fatal(err) } defer dbp.Close() + hugoProvider, err := hugo.New(cfg.StagingDir()) + if err != nil { + log.Fatal(err) + } + gitProvider := git.New() + themesProvider := themes.New() + + siteService := sites.NewService(cfg, dbp, themesProvider, gitProvider, hugoProvider) + log.Println("Connected to database") if err := dbp.Migrate(context.Background()); err != nil { log.Fatal(err) @@ -21,10 +40,7 @@ func main() { log.Println("Database migrated") - if err := dbp.InsertSite(context.Background(), &models.Site{ - Name: "Test site", - URL: "https://www.testsite.com", - }); err != nil { + if _, err := siteService.CreateSite(context.Background(), "Test site "+time.Now().Format("2006-01-02T15:04:05")); err != nil { log.Fatal(err) } } diff --git a/models/sites.go b/models/sites.go index d2d45ed..9685c60 100644 --- a/models/sites.go +++ b/models/sites.go @@ -1,7 +1,9 @@ package models type Site struct { - ID int64 - Name string - URL string + ID int64 + Name string + Title string + URL string + Theme string } diff --git a/models/theme.go b/models/theme.go new file mode 100644 index 0000000..51b9f88 --- /dev/null +++ b/models/theme.go @@ -0,0 +1,6 @@ +package models + +type ThemeMeta struct { + Name string `json:"name"` + URL string `json:"repo"` +} diff --git a/providers/db/sites.go b/providers/db/sites.go index 98131e8..e16b89d 100644 --- a/providers/db/sites.go +++ b/providers/db/sites.go @@ -10,7 +10,7 @@ func (db *DB) InsertSite(ctx context.Context, site *models.Site) error { id, err := db.q.NewSite(ctx, dbq.NewSiteParams{ Name: site.Name, Url: site.URL, - Theme: "default", + Theme: site.Theme, Props: []byte("{}"), }) if err != nil { diff --git a/providers/git/provider.go b/providers/git/provider.go new file mode 100644 index 0000000..dbb9dd8 --- /dev/null +++ b/providers/git/provider.go @@ -0,0 +1,17 @@ +package git + +import ( + "context" + "os/exec" +) + +type Provider struct { +} + +func New() *Provider { + return &Provider{} +} + +func (p *Provider) Clone(ctx context.Context, url string, targetDir string) error { + return exec.CommandContext(ctx, "git", "clone", url, targetDir).Run() +} diff --git a/providers/hugo/dirs.go b/providers/hugo/dirs.go new file mode 100644 index 0000000..15d6483 --- /dev/null +++ b/providers/hugo/dirs.go @@ -0,0 +1,8 @@ +package hugo + +type SiteDir string + +const ( + BaseSiteDir SiteDir = "base" + ThemeSiteDir SiteDir = "theme" +) diff --git a/providers/hugo/provider.go b/providers/hugo/provider.go new file mode 100644 index 0000000..48cec47 --- /dev/null +++ b/providers/hugo/provider.go @@ -0,0 +1,70 @@ +package hugo + +import ( + "bytes" + "context" + "lmika.dev/lmika/hugo-crm/models" + "lmika.dev/lmika/hugo-crm/providers/hugo/tmpls" + "log" + "os" + "os/exec" + "path/filepath" + "text/template" +) + +type Provider struct { + stagingDir string + tmpls *template.Template +} + +func New(stagingDir string) (*Provider, error) { + ts, err := template.ParseFS(tmpls.FS, "*.tmpl") + if err != nil { + return nil, err + } + + return &Provider{ + stagingDir: stagingDir, + tmpls: ts, + }, nil +} + +func (p *Provider) SiteStagingDir(site models.Site, what SiteDir) string { + switch what { + case BaseSiteDir: + return filepath.Join(p.stagingDir, site.Name) + case ThemeSiteDir: + return filepath.Join(p.stagingDir, site.Name, "themes", site.Theme) + } + return "" +} + +func (p *Provider) NewSite(ctx context.Context, site models.Site) error { + log.Printf(" .. %v", p.SiteStagingDir(site, BaseSiteDir)) + if err := os.MkdirAll(p.SiteStagingDir(site, BaseSiteDir), 0755); err != nil { + return err + } + + // Create the new site + if err := exec.CommandContext(ctx, "hugo", "new", "site", p.SiteStagingDir(site, BaseSiteDir)).Run(); err != nil { + return err + } + return nil +} + +func (p *Provider) ReconfigureSite(ctx context.Context, site models.Site) error { + // Reconfigure the site + var hugoCfg bytes.Buffer + if err := p.tmpls.ExecuteTemplate(&hugoCfg, "config.toml.tmpl", struct { + Site models.Site + }{ + Site: site, + }); err != nil { + return err + } + + if err := os.WriteFile(filepath.Join(p.SiteStagingDir(site, BaseSiteDir), "hugo.toml"), hugoCfg.Bytes(), 0644); err != nil { + return err + } + return nil +} diff --git a/providers/hugo/tmpls/config.toml.tmpl b/providers/hugo/tmpls/config.toml.tmpl new file mode 100644 index 0000000..c74445d --- /dev/null +++ b/providers/hugo/tmpls/config.toml.tmpl @@ -0,0 +1,5 @@ +baseURL = {{.Site.URL | printf "%q"}} +languageCode = 'en-us' +title = {{.Site.Title | printf "%q"}} + +theme = {{.Site.Theme | printf "%q"}} \ No newline at end of file diff --git a/providers/hugo/tmpls/fs.go b/providers/hugo/tmpls/fs.go new file mode 100644 index 0000000..f3d0429 --- /dev/null +++ b/providers/hugo/tmpls/fs.go @@ -0,0 +1,6 @@ +package tmpls + +import "embed" + +//go:embed *.tmpl +var FS embed.FS diff --git a/providers/themes/meta.go b/providers/themes/meta.go new file mode 100644 index 0000000..f65158d --- /dev/null +++ b/providers/themes/meta.go @@ -0,0 +1,10 @@ +package themes + +import "lmika.dev/lmika/hugo-crm/models" + +var themes = map[string]models.ThemeMeta{ + "bear": models.ThemeMeta{ + Name: "bear", + URL: "https://github.com/janraasch/hugo-bearblog", + }, +} diff --git a/providers/themes/provider.go b/providers/themes/provider.go new file mode 100644 index 0000000..610094c --- /dev/null +++ b/providers/themes/provider.go @@ -0,0 +1,14 @@ +package themes + +import "lmika.dev/lmika/hugo-crm/models" + +type Provider struct{} + +func New() *Provider { + return &Provider{} +} + +func (p *Provider) Lookup(name string) (models.ThemeMeta, bool) { + t, ok := themes[name] + return t, ok +} diff --git a/services/sites/service.go b/services/sites/service.go new file mode 100644 index 0000000..2c974f1 --- /dev/null +++ b/services/sites/service.go @@ -0,0 +1,91 @@ +package sites + +import ( + "context" + "errors" + "lmika.dev/lmika/hugo-crm/config" + "lmika.dev/lmika/hugo-crm/models" + "lmika.dev/lmika/hugo-crm/providers/db" + "lmika.dev/lmika/hugo-crm/providers/git" + "lmika.dev/lmika/hugo-crm/providers/hugo" + "lmika.dev/lmika/hugo-crm/providers/themes" + "log" + "strings" + "unicode" +) + +type Service struct { + cfg config.Config + db *db.DB + themes *themes.Provider + git *git.Provider + hugo *hugo.Provider +} + +func NewService( + cfg config.Config, + db *db.DB, + themes *themes.Provider, + git *git.Provider, + hugo *hugo.Provider, +) *Service { + return &Service{ + cfg: cfg, + db: db, + git: git, + hugo: hugo, + } +} + +func (s *Service) CreateSite(ctx context.Context, name string) (models.Site, error) { + newSite := models.Site{ + Name: normaliseName(name), + Title: name, + Theme: "bear", + } + + themeMeta, ok := s.themes.Lookup(newSite.Theme) + if !ok { + return models.Site{}, errors.New("theme not found") + } + + if err := s.db.InsertSite(ctx, &newSite); err != nil { + return models.Site{}, err + } + + // Build the site + log.Printf(" .. build") + if err := s.hugo.NewSite(ctx, newSite); err != nil { + return models.Site{}, err + } + + // Setup the theme + log.Printf(" .. theme") + if err := s.git.Clone(ctx, themeMeta.URL, s.hugo.SiteStagingDir(newSite, hugo.ThemeSiteDir)); err != nil { + return models.Site{}, err + } + + if err := s.hugo.ReconfigureSite(ctx, newSite); err != nil { + return models.Site{}, err + } + + return newSite, nil +} + +func normaliseName(name string) string { + var sb strings.Builder + + seenDash := false + for _, r := range strings.TrimSpace(name) { + if unicode.IsLetter(r) || unicode.IsNumber(r) { + sb.WriteRune(unicode.ToLower(r)) + seenDash = false + } else if r == '_' || r == '-' || r == '.' || unicode.IsSpace(r) { + if !seenDash { + sb.WriteRune('-') + } + seenDash = true + } + } + return sb.String() +} diff --git a/sql/queries/sites.sql b/sql/queries/sites.sql index bc5626a..e1bc9df 100644 --- a/sql/queries/sites.sql +++ b/sql/queries/sites.sql @@ -4,8 +4,9 @@ SELECT * FROM site; -- name: NewSite :one INSERT INTO site ( name, + title, url, theme, props -) VALUES ($1, $2, $3, $4) +) VALUES ($1, $2, $3, $4, $5) RETURNING id; \ No newline at end of file