From 7ef6725bdb8ecefd20822ab69ac7d0d14975be43 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 27 Jan 2025 15:45:53 +1100 Subject: [PATCH] Have got publishing to Netlify --- .gitignore | 5 +- config/config.go | 30 ++++--- gen/sqlc/dbq/models.go | 91 +++++++++++++++++++++ gen/sqlc/dbq/posts.sql.go | 42 ++++++++++ gen/sqlc/dbq/targets.sql.go | 73 +++++++++++++++++ go.mod | 1 + go.sum | 2 + handlers/site.go | 12 ++- main.go | 8 +- models/job.go | 11 +++ models/publish.go | 22 ++++++ providers/db/posts.go | 38 ++++++--- providers/db/publish.go | 42 ++++++++++ providers/hugo/provider.go | 35 ++++++++- providers/netlify/provider.go | 29 +++++++ services/sitebuilder/posts.go | 98 +++++++++++++++++++++++ services/sitebuilder/publish.go | 40 ++++++++++ services/sitebuilder/service.go | 135 +++++++++++++------------------- services/sites/service.go | 19 +++++ sql/queries/posts.sql | 6 ++ sql/queries/targets.sql | 12 +++ sql/schema/1_init.up.sql | 21 ++++- templates/sites/posts.html | 4 + 23 files changed, 667 insertions(+), 109 deletions(-) create mode 100644 gen/sqlc/dbq/targets.sql.go create mode 100644 models/publish.go create mode 100644 providers/db/publish.go create mode 100644 providers/netlify/provider.go create mode 100644 services/sitebuilder/posts.go create mode 100644 services/sitebuilder/publish.go create mode 100644 sql/queries/targets.sql diff --git a/.gitignore b/.gitignore index 1fd9d10..ad3ea1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .idea -build/ \ No newline at end of file +build/ +.env +# Local Netlify folder +.netlify diff --git a/config/config.go b/config/config.go index 0f68ed0..1eecf8c 100644 --- a/config/config.go +++ b/config/config.go @@ -1,22 +1,30 @@ package config -import "path/filepath" +import ( + "github.com/Netflix/go-env" + "path/filepath" +) type Config struct { - DatabaseURL string `env:"DATABASE_URL"` - - DataDir string `env:"DATA_DIR"` - DataStagingDir string `env:"DATA_STAGING_DIR,default=staging"` + DatabaseURL string `env:"DATABASE_URL"` + NetlifyAuthToken string `env:"NETLIFY_AUTH_TOKEN"` + DataDir string `env:"DATA_DIR"` + DataStagingDir string `env:"DATA_STAGING_DIR,default=staging"` + DataScratchDir string `env:"DATA_SCRATCH_DIR,default=scratch"` } -func Load() (Config, error) { - return Config{ - DatabaseURL: "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable", - DataDir: "build/data", - DataStagingDir: "staging", - }, nil +func Load() (cfg Config, err error) { + _, err = env.UnmarshalFromEnviron(&cfg) + if err != nil { + return Config{}, err + } + return cfg, nil } func (c Config) StagingDir() string { return filepath.Join(c.DataDir, c.DataStagingDir) } + +func (c Config) ScratchDir() string { + return filepath.Join(c.DataDir, c.DataScratchDir) +} diff --git a/gen/sqlc/dbq/models.go b/gen/sqlc/dbq/models.go index b816be7..b723142 100644 --- a/gen/sqlc/dbq/models.go +++ b/gen/sqlc/dbq/models.go @@ -53,6 +53,88 @@ func (ns NullPostState) Value() (driver.Value, error) { return string(ns.PostState), nil } +type TargetRole string + +const ( + TargetRoleProduction TargetRole = "production" +) + +func (e *TargetRole) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = TargetRole(s) + case string: + *e = TargetRole(s) + default: + return fmt.Errorf("unsupported scan type for TargetRole: %T", src) + } + return nil +} + +type NullTargetRole struct { + TargetRole TargetRole + Valid bool // Valid is true if TargetRole is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullTargetRole) Scan(value interface{}) error { + if value == nil { + ns.TargetRole, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.TargetRole.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullTargetRole) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.TargetRole), nil +} + +type TargetType string + +const ( + TargetTypeNetlify TargetType = "netlify" +) + +func (e *TargetType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = TargetType(s) + case string: + *e = TargetType(s) + default: + return fmt.Errorf("unsupported scan type for TargetType: %T", src) + } + return nil +} + +type NullTargetType struct { + TargetType TargetType + Valid bool // Valid is true if TargetType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullTargetType) Scan(value interface{}) error { + if value == nil { + ns.TargetType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.TargetType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullTargetType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.TargetType), nil +} + type Post struct { ID int64 SiteID int64 @@ -64,6 +146,15 @@ type Post struct { CreatedAt pgtype.Timestamp } +type PublishTarget struct { + ID int64 + SiteID int64 + Role TargetRole + TargetType TargetType + Url string + TargetRef string +} + type Site struct { ID int64 Name string diff --git a/gen/sqlc/dbq/posts.sql.go b/gen/sqlc/dbq/posts.sql.go index a6232ca..00bc58e 100644 --- a/gen/sqlc/dbq/posts.sql.go +++ b/gen/sqlc/dbq/posts.sql.go @@ -81,3 +81,45 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { } return items, nil } + +const listPublishablePosts = `-- name: ListPublishablePosts :many +SELECT id, site_id, title, body, state, props, post_date, created_at +FROM post +WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 +ORDER BY id LIMIT 100 +` + +type ListPublishablePostsParams struct { + ID int64 + SiteID int64 + PostDate pgtype.Timestamptz +} + +func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishablePostsParams) ([]Post, error) { + rows, err := q.db.Query(ctx, listPublishablePosts, arg.ID, arg.SiteID, arg.PostDate) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Post + for rows.Next() { + var i Post + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Title, + &i.Body, + &i.State, + &i.Props, + &i.PostDate, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/gen/sqlc/dbq/targets.sql.go b/gen/sqlc/dbq/targets.sql.go new file mode 100644 index 0000000..12712bd --- /dev/null +++ b/gen/sqlc/dbq/targets.sql.go @@ -0,0 +1,73 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: targets.sql + +package dbq + +import ( + "context" +) + +const insertPublishTarget = `-- name: InsertPublishTarget :one +INSERT INTO publish_target ( + site_id, + role, + target_type, + url, + target_ref +) VALUES ($1, $2, $3, $4, $5) +RETURNING id +` + +type InsertPublishTargetParams struct { + SiteID int64 + Role TargetRole + TargetType TargetType + Url string + TargetRef string +} + +func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTargetParams) (int64, error) { + row := q.db.QueryRow(ctx, insertPublishTarget, + arg.SiteID, + arg.Role, + arg.TargetType, + arg.Url, + arg.TargetRef, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const listPublishTargetsOfRole = `-- name: ListPublishTargetsOfRole :many +SELECT id, site_id, role, target_type, url, target_ref FROM publish_target WHERE site_id = $1 AND role = 'production' +` + +func (q *Queries) ListPublishTargetsOfRole(ctx context.Context, siteID int64) ([]PublishTarget, error) { + rows, err := q.db.Query(ctx, listPublishTargetsOfRole, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PublishTarget + for rows.Next() { + var i PublishTarget + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Role, + &i.TargetType, + &i.Url, + &i.TargetRef, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/go.mod b/go.mod index 1006f39..fe52690 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/Netflix/go-env v0.1.2 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gofiber/fiber/v2 v2.52.6 // indirect diff --git a/go.sum b/go.sum index e25c36a..51217bd 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0= +github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/handlers/site.go b/handlers/site.go index 43b3fda..33b3fa3 100644 --- a/handlers/site.go +++ b/handlers/site.go @@ -19,7 +19,7 @@ func (s *Site) Create() fiber.Handler { return err } - return c.Redirect(fmt.Sprintf("/sites/%v", site.ID)) + return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID)) } } @@ -41,6 +41,16 @@ func (s *Site) Show() fiber.Handler { } } +func (s *Site) Rebuild() fiber.Handler { + return func(c *fiber.Ctx) error { + if err := s.Site.Rebuild(c.UserContext(), GetSite(c)); err != nil { + return err + } + + return c.Redirect(fmt.Sprintf("/sites/%v/posts", GetSite(c).ID)) + } +} + func (s *Site) WithSite() fiber.Handler { return func(c *fiber.Ctx) error { id, err := c.ParamsInt("siteId") diff --git a/main.go b/main.go index f347cab..96ba629 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "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/netlify" "lmika.dev/lmika/hugo-crm/providers/themes" "lmika.dev/lmika/hugo-crm/services/jobs" "lmika.dev/lmika/hugo-crm/services/posts" @@ -31,15 +32,16 @@ func main() { } defer dbp.Close() - hugoProvider, err := hugo.New(cfg.StagingDir()) + hugoProvider, err := hugo.New(cfg.StagingDir(), cfg.ScratchDir()) if err != nil { log.Fatal(err) } gitProvider := git.New() themesProvider := themes.New() + netlifyProvider := netlify.New(cfg.NetlifyAuthToken) jobService := jobs.New() - siteBuilderService := sitebuilder.New(themesProvider, gitProvider, hugoProvider) + siteBuilderService := sitebuilder.New(dbp, themesProvider, gitProvider, hugoProvider, netlifyProvider) siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService) postService := posts.New(dbp, siteBuilderService, jobService) @@ -69,6 +71,8 @@ func main() { siteGroup := app.Group("/sites/:siteId") siteGroup.Use(siteHandlers.WithSite()) + siteGroup.Post("/rebuild", siteHandlers.Rebuild()) + siteGroup.Get("/posts", postHandlers.Posts()) siteGroup.Post("/posts", postHandlers.Create()) diff --git a/models/job.go b/models/job.go index c082b20..fa6b040 100644 --- a/models/job.go +++ b/models/job.go @@ -5,3 +5,14 @@ import "context" type Job struct { Do func(ctx context.Context) error } + +func Jobs(jobs ...Job) Job { + return Job{Do: func(ctx context.Context) error { + for _, job := range jobs { + if err := job.Do(ctx); err != nil { + return err + } + } + return nil + }} +} diff --git a/models/publish.go b/models/publish.go new file mode 100644 index 0000000..92ddf82 --- /dev/null +++ b/models/publish.go @@ -0,0 +1,22 @@ +package models + +type TargetRole string + +const ( + TargetRoleProduction TargetRole = "production" +) + +type TargetType string + +const ( + TargetTypeNetlify TargetType = "netlify" +) + +type PublishTarget struct { + ID int64 + SiteID int64 + Role TargetRole + Type TargetType + URL string + TargetRef string +} diff --git a/providers/db/posts.go b/providers/db/posts.go index c875c14..cff8bb7 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -6,6 +6,7 @@ import ( "lmika.dev/lmika/hugo-crm/gen/sqlc/dbq" "lmika.dev/lmika/hugo-crm/models" "lmika.dev/pkg/modash/moslice" + "time" ) func (db *DB) ListPostsOfSite(ctx context.Context, siteID int64) ([]models.Post, error) { @@ -14,17 +15,20 @@ func (db *DB) ListPostsOfSite(ctx context.Context, siteID int64) ([]models.Post, return nil, err } - return moslice.Map(res, func(p dbq.Post) models.Post { - return models.Post{ - ID: p.ID, - SiteID: p.SiteID, - Title: p.Title.String, - Body: p.Body, - State: models.PostState(p.State), - PostDate: p.PostDate.Time, - CreatedAt: p.CreatedAt.Time, - } - }), nil + return moslice.Map(res, dbPostToPost), nil +} + +func (db *DB) ListPublishablePosts(ctx context.Context, fromID, siteID int64, now time.Time) ([]models.Post, error) { + res, err := db.q.ListPublishablePosts(ctx, dbq.ListPublishablePostsParams{ + ID: fromID, + SiteID: siteID, + PostDate: pgtype.Timestamptz{Time: now, Valid: true}, + }) + if err != nil { + return nil, err + } + + return moslice.Map(res, dbPostToPost), nil } func (db *DB) InsertPost(ctx context.Context, p *models.Post) error { @@ -44,3 +48,15 @@ func (db *DB) InsertPost(ctx context.Context, p *models.Post) error { p.ID = res return nil } + +func dbPostToPost(p dbq.Post) models.Post { + return models.Post{ + ID: p.ID, + SiteID: p.SiteID, + Title: p.Title.String, + Body: p.Body, + State: models.PostState(p.State), + PostDate: p.PostDate.Time, + CreatedAt: p.CreatedAt.Time, + } +} diff --git a/providers/db/publish.go b/providers/db/publish.go new file mode 100644 index 0000000..88b7082 --- /dev/null +++ b/providers/db/publish.go @@ -0,0 +1,42 @@ +package db + +import ( + "context" + "lmika.dev/lmika/hugo-crm/gen/sqlc/dbq" + "lmika.dev/lmika/hugo-crm/models" + "lmika.dev/pkg/modash/moslice" +) + +func (db *DB) InsertPublishTarget(ctx context.Context, target *models.PublishTarget) error { + id, err := db.q.InsertPublishTarget(ctx, dbq.InsertPublishTargetParams{ + SiteID: target.SiteID, + Role: dbq.TargetRole(target.Role), + TargetType: dbq.TargetType(target.Type), + Url: target.URL, + TargetRef: target.TargetRef, + }) + if err != nil { + return err + } + target.ID = id + + return nil +} + +func (db *DB) GetPublishTargets(ctx context.Context, siteID int64) ([]models.PublishTarget, error) { + res, err := db.q.ListPublishTargetsOfRole(ctx, siteID) + if err != nil { + return nil, err + } + + return moslice.Map(res, func(m dbq.PublishTarget) models.PublishTarget { + return models.PublishTarget{ + ID: m.ID, + SiteID: m.SiteID, + Role: models.TargetRole(m.Role), + Type: models.TargetType(m.TargetType), + URL: m.Url, + TargetRef: m.TargetRef, + } + }), nil +} diff --git a/providers/hugo/provider.go b/providers/hugo/provider.go index a1b26bb..1948535 100644 --- a/providers/hugo/provider.go +++ b/providers/hugo/provider.go @@ -14,10 +14,11 @@ import ( type Provider struct { stagingDir string + scratchDir string tmpls *template.Template } -func New(stagingDir string) (*Provider, error) { +func New(stagingDir, scratchDir string) (*Provider, error) { ts, err := template.ParseFS(tmpls.FS, "*.tmpl") if err != nil { return nil, err @@ -25,6 +26,7 @@ func New(stagingDir string) (*Provider, error) { return &Provider{ stagingDir: stagingDir, + scratchDir: scratchDir, tmpls: ts, }, nil } @@ -52,6 +54,37 @@ func (p *Provider) NewSite(ctx context.Context, site models.Site) error { return nil } +func (p *Provider) PublishSite(ctx context.Context, site models.Site, target models.PublishTarget) (outDir string, clean func(), err error) { + if err := os.MkdirAll(p.scratchDir, 0755); err != nil { + return "", nil, err + } + + outDir, err = os.MkdirTemp(p.scratchDir, site.Name+"-*") + if err != nil { + return "", nil, err + } + clean = func() { + os.RemoveAll(outDir) + } + + outDir, err = filepath.Abs(outDir) + if err != nil { + return "", nil, err + } + + cmd := exec.CommandContext(ctx, "hugo", + "--source", p.SiteStagingDir(site, BaseSiteDir), + "--destination", outDir, + "--baseURL", target.URL) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + + if err := cmd.Run(); err != nil { + return "", clean, err + } + return outDir, clean, nil +} + func (p *Provider) ReconfigureSite(ctx context.Context, site models.Site) error { // Reconfigure the site var hugoCfg bytes.Buffer diff --git a/providers/netlify/provider.go b/providers/netlify/provider.go new file mode 100644 index 0000000..2dfce1c --- /dev/null +++ b/providers/netlify/provider.go @@ -0,0 +1,29 @@ +package netlify + +import ( + "context" + "fmt" + "lmika.dev/lmika/hugo-crm/models" + "os" + "os/exec" +) + +type Provider struct { + authToken string +} + +func New(authToken string) *Provider { + return &Provider{ + authToken: authToken, + } +} + +func (p *Provider) Publish(ctx context.Context, target models.PublishTarget, dir string) error { + cmd := exec.CommandContext(ctx, "netlify", "deploy", "--dir", dir, "--prod") + cmd.Env = append(os.Environ(), + fmt.Sprintf("NETLIFY_SITE_ID=%v", target.TargetRef), + fmt.Sprintf("NETLIFY_AUTH_TOKEN=%v", p.authToken)) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/services/sitebuilder/posts.go b/services/sitebuilder/posts.go new file mode 100644 index 0000000..9fbe3ed --- /dev/null +++ b/services/sitebuilder/posts.go @@ -0,0 +1,98 @@ +package sitebuilder + +import ( + "context" + "errors" + "gopkg.in/yaml.v3" + "lmika.dev/lmika/hugo-crm/models" + "lmika.dev/lmika/hugo-crm/providers/hugo" + "log" + "os" + "path/filepath" + "time" +) + +func (s *Service) WritePost(site models.Site, post models.Post) models.Job { + return models.Jobs( + models.Job{ + Do: func(ctx context.Context) error { + return s.writePost(site, post) + }, + }, + s.Publish(site), + ) +} + +func (s *Service) WriteAllPosts(site models.Site) models.Job { + return models.Job{ + Do: func(ctx context.Context) error { + var startId int64 + now := time.Now() + for { + posts, err := s.db.ListPublishablePosts(ctx, int64(startId), site.ID, now) + if err != nil { + return err + } else if len(posts) == 0 { + return nil + } + + for _, post := range posts { + if err := s.writePost(site, post); err != nil { + return err + } + } + startId = posts[len(posts)-1].ID + } + }, + } +} + +func (s *Service) writePost(site models.Site, post models.Post) error { + themeMeta, ok := s.themes.Lookup(site.Theme) + if !ok { + return errors.New("theme not found") + } + + postFilename := filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md")) + + log.Printf(" .. post %v", postFilename) + + if err := os.MkdirAll(filepath.Dir(postFilename), 0755); err != nil { + return err + } + + frontMatter := map[string]string{ + "date": post.PostDate.Format(time.RFC3339), + } + if post.Title != "" { + frontMatter["title"] = post.Title + } else if themeMeta.PreferTitle { + frontMatter["title"] = post.PostDate.Format(time.ANSIC) + } + + fmBytes, err := yaml.Marshal(frontMatter) + if err != nil { + return err + } + + f, err := os.Create(postFilename) + if err != nil { + return err + } + defer f.Close() + + if _, err := f.WriteString("---\n"); err != nil { + return err + } + if _, err := f.Write(fmBytes); err != nil { + return err + } + if _, err := f.WriteString("---\n"); err != nil { + return err + } + if _, err := f.WriteString(post.Body); err != nil { + return err + } + + return nil +} diff --git a/services/sitebuilder/publish.go b/services/sitebuilder/publish.go new file mode 100644 index 0000000..43e78d6 --- /dev/null +++ b/services/sitebuilder/publish.go @@ -0,0 +1,40 @@ +package sitebuilder + +import ( + "context" + "lmika.dev/lmika/hugo-crm/models" +) + +func (s *Service) Publish(site models.Site) models.Job { + return models.Job{ + Do: func(ctx context.Context) error { + targets, err := s.db.GetPublishTargets(ctx, site.ID) + if err != nil { + return err + } + + for _, target := range targets { + if err := s.publishTarget(ctx, site, target); err != nil { + return err + } + } + return nil + }, + } +} + +func (s *Service) publishTarget(ctx context.Context, site models.Site, target models.PublishTarget) error { + outDir, cleanFn, err := s.hugo.PublishSite(ctx, site, target) + //defer func() { + // if cleanFn != nil { + // cleanFn() + // } + //}() + _ = cleanFn + + if err != nil { + return err + } + + return s.netlify.Publish(ctx, target, outDir) +} diff --git a/services/sitebuilder/service.go b/services/sitebuilder/service.go index 74f8f65..c99d107 100644 --- a/services/sitebuilder/service.go +++ b/services/sitebuilder/service.go @@ -3,113 +3,86 @@ package sitebuilder import ( "context" "errors" - "gopkg.in/yaml.v3" "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/netlify" "lmika.dev/lmika/hugo-crm/providers/themes" "log" "os" - "path/filepath" - "time" ) type Service struct { - themes *themes.Provider - git *git.Provider - hugo *hugo.Provider + db *db.DB + themes *themes.Provider + git *git.Provider + hugo *hugo.Provider + netlify *netlify.Provider } func New( + db *db.DB, themes *themes.Provider, git *git.Provider, hugo *hugo.Provider, + netlify *netlify.Provider, ) *Service { return &Service{ - themes: themes, - git: git, - hugo: hugo, + db: db, + themes: themes, + git: git, + hugo: hugo, + netlify: netlify, } } func (s *Service) CreateNewSite(site models.Site) models.Job { return models.Job{ Do: func(ctx context.Context) error { - themeMeta, ok := s.themes.Lookup(site.Theme) - if !ok { - return errors.New("theme not found") - } - - // Build the site - log.Printf(" .. build") - if err := s.hugo.NewSite(ctx, site); err != nil { - return err - } - - // Setup the theme - log.Printf(" .. theme") - if err := s.git.Clone(ctx, themeMeta.URL, s.hugo.SiteStagingDir(site, hugo.ThemeSiteDir)); err != nil { - return err - } - - if err := s.hugo.ReconfigureSite(ctx, site); err != nil { - return err - } - return nil + return s.createSite(ctx, site) }, } } -func (s *Service) WritePost(site models.Site, post models.Post) models.Job { - return models.Job{ - Do: func(ctx context.Context) error { - themeMeta, ok := s.themes.Lookup(site.Theme) - if !ok { - return errors.New("theme not found") - } - - postFilename := filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md")) - - log.Printf(" .. post %v", postFilename) - - if err := os.MkdirAll(filepath.Dir(postFilename), 0755); err != nil { - return err - } - - frontMatter := map[string]string{ - "date": post.PostDate.Format(time.RFC3339), - } - if post.Title != "" { - frontMatter["title"] = post.Title - } else if themeMeta.PreferTitle { - frontMatter["title"] = post.PostDate.Format(time.ANSIC) - } - - fmBytes, err := yaml.Marshal(frontMatter) - if err != nil { - return err - } - - f, err := os.Create(postFilename) - if err != nil { - return err - } - defer f.Close() - - if _, err := f.WriteString("---\n"); err != nil { - return err - } - if _, err := f.Write(fmBytes); err != nil { - return err - } - if _, err := f.WriteString("---\n"); err != nil { - return err - } - if _, err := f.WriteString(post.Body); err != nil { - return err - } - - return nil +func (s *Service) RebuildSite(site models.Site) models.Job { + return models.Jobs( + models.Job{ + Do: func(ctx context.Context) error { + // Teardown the existing site + siteDir := s.hugo.SiteStagingDir(site, hugo.BaseSiteDir) + if err := os.RemoveAll(siteDir); err != nil { + return err + } + return nil + }, }, - } + s.CreateNewSite(site), + s.WriteAllPosts(site), + s.Publish(site), + ) +} + +func (s *Service) createSite(ctx context.Context, site models.Site) error { + themeMeta, ok := s.themes.Lookup(site.Theme) + if !ok { + return errors.New("theme not found") + } + + // Build the site + log.Printf(" .. build") + if err := s.hugo.NewSite(ctx, site); err != nil { + return err + } + + // Setup the theme + log.Printf(" .. theme") + if err := s.git.Clone(ctx, themeMeta.URL, s.hugo.SiteStagingDir(site, hugo.ThemeSiteDir)); err != nil { + return err + } + + if err := s.hugo.ReconfigureSite(ctx, site); err != nil { + return err + } + return nil } diff --git a/services/sites/service.go b/services/sites/service.go index 2c714dd..0ddb42e 100644 --- a/services/sites/service.go +++ b/services/sites/service.go @@ -57,9 +57,28 @@ func (s *Service) CreateSite(ctx context.Context, name string) (models.Site, err return models.Site{}, err } + // TEMP + if err := s.db.InsertPublishTarget(ctx, &models.PublishTarget{ + SiteID: newSite.ID, + Role: models.TargetRoleProduction, + Type: models.TargetTypeNetlify, + URL: "https://meek-meringue-060cfc.netlify.app", + TargetRef: "e628dc6e-e6e1-45a9-847a-982adef940a8", + }); err != nil { + return models.Site{}, err + } + return newSite, s.jobs.Queue(ctx, s.sb.CreateNewSite(newSite)) } +func (s *Service) Rebuild(ctx context.Context, site models.Site) error { + if site.ID == 0 { + return errors.New("site id required") + } + + return s.jobs.Queue(ctx, s.sb.RebuildSite(site)) +} + func normaliseName(name string) string { var sb strings.Builder diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 1ace262..28cc808 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,6 +1,12 @@ -- name: ListPosts :many SELECT * FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25; +-- name: ListPublishablePosts :many +SELECT * +FROM post +WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 +ORDER BY id LIMIT 100; + -- name: InsertPost :one INSERT INTO post ( site_id, diff --git a/sql/queries/targets.sql b/sql/queries/targets.sql new file mode 100644 index 0000000..55be1d6 --- /dev/null +++ b/sql/queries/targets.sql @@ -0,0 +1,12 @@ +-- name: ListPublishTargetsOfRole :many +SELECT * FROM publish_target WHERE site_id = $1 AND role = 'production'; + +-- name: InsertPublishTarget :one +INSERT INTO publish_target ( + site_id, + role, + target_type, + url, + target_ref +) VALUES ($1, $2, $3, $4, $5) +RETURNING id; \ No newline at end of file diff --git a/sql/schema/1_init.up.sql b/sql/schema/1_init.up.sql index 1e63525..15b6253 100644 --- a/sql/schema/1_init.up.sql +++ b/sql/schema/1_init.up.sql @@ -3,6 +3,14 @@ CREATE TYPE post_state AS ENUM ( 'published' ); +CREATE TYPE target_role AS ENUM ( + 'production' +); + +CREATE TYPE target_type AS ENUM ( + 'netlify' +); + CREATE TABLE site ( id BIGSERIAL NOT NULL PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -22,5 +30,16 @@ CREATE TABLE post ( post_date TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP NOT NULL, - FOREIGN KEY (site_id) REFERENCES site(id) + FOREIGN KEY (site_id) REFERENCES site (id) +); + +CREATE TABLE publish_target ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + role target_role NOT NULL, + target_type target_type NOT NULL, + url TEXT NOT NULL, + target_ref TEXT NOT NULL, + + FOREIGN KEY (site_id) REFERENCES site (id) ); \ No newline at end of file diff --git a/templates/sites/posts.html b/templates/sites/posts.html index 1f9348f..b847fd8 100644 --- a/templates/sites/posts.html +++ b/templates/sites/posts.html @@ -5,6 +5,10 @@ +
+ +
+ {{range .posts}}
{{.Body}}