From ba12398d2f2284f4cb9d94de565bb1b1d0871396 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 16 Feb 2025 11:43:22 +1100 Subject: [PATCH 1/5] Started working on pages --- gen/sqlc/dbq/bundles.sql.go | 81 +++++++++++ gen/sqlc/dbq/models.go | 87 +++++++++++- gen/sqlc/dbq/pages.sql.go | 233 ++++++++++++++++++++++++++++++++ gen/sqlc/dbq/posts.sql.go | 75 +++++----- go.mod | 2 +- go.sum | 2 + handlers/page.go | 123 +++++++++++++++++ main.go | 10 ++ models/bundle.go | 40 ++++++ models/posts.go | 18 +-- models/theme.go | 4 +- providers/db/bundles.go | 50 +++++++ providers/db/page.go | 105 ++++++++++++++ providers/db/posts.go | 51 +++---- providers/themes/meta.go | 30 ++-- services/pages/services.go | 183 +++++++++++++++++++++++++ services/posts/services.go | 10 +- services/sitebuilder/pages.go | 105 ++++++++++++++ services/sitebuilder/posts.go | 37 +++-- services/sitebuilder/service.go | 5 + services/sitebuilder/tracker.go | 32 +++++ services/sites/create.go | 55 ++++++++ services/sites/service.go | 21 --- sql/queries/bundles.sql | 13 ++ sql/queries/pages.sql | 47 +++++++ sql/queries/posts.sql | 15 +- sql/schema/1_init.up.sql | 68 ++++++++-- templates/fs.go | 1 + templates/pages/edit.html | 13 ++ templates/pages/index.html | 20 +++ 30 files changed, 1391 insertions(+), 145 deletions(-) create mode 100644 gen/sqlc/dbq/bundles.sql.go create mode 100644 gen/sqlc/dbq/pages.sql.go create mode 100644 handlers/page.go create mode 100644 models/bundle.go create mode 100644 providers/db/bundles.go create mode 100644 providers/db/page.go create mode 100644 services/pages/services.go create mode 100644 services/sitebuilder/pages.go create mode 100644 services/sitebuilder/tracker.go create mode 100644 services/sites/create.go create mode 100644 sql/queries/bundles.sql create mode 100644 sql/queries/pages.sql create mode 100644 templates/pages/edit.html create mode 100644 templates/pages/index.html diff --git a/gen/sqlc/dbq/bundles.sql.go b/gen/sqlc/dbq/bundles.sql.go new file mode 100644 index 0000000..d6f11d8 --- /dev/null +++ b/gen/sqlc/dbq/bundles.sql.go @@ -0,0 +1,81 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: bundles.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getBundleWithID = `-- name: GetBundleWithID :one +SELECT id, site_id, name, created_at, updated_at FROM bundles WHERE id = $1 +` + +func (q *Queries) GetBundleWithID(ctx context.Context, id int64) (Bundle, error) { + row := q.db.QueryRow(ctx, getBundleWithID, id) + var i Bundle + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertBundle = `-- name: InsertBundle :one +INSERT INTO bundles ( + site_id, + name, + created_at, + updated_at +) VALUES ($1, $2, $3, $3) RETURNING id +` + +type InsertBundleParams struct { + SiteID int64 + Name string + CreatedAt pgtype.Timestamp +} + +func (q *Queries) InsertBundle(ctx context.Context, arg InsertBundleParams) (int64, error) { + row := q.db.QueryRow(ctx, insertBundle, arg.SiteID, arg.Name, arg.CreatedAt) + var id int64 + err := row.Scan(&id) + return id, err +} + +const listBundles = `-- name: ListBundles :many +SELECT id, site_id, name, created_at, updated_at FROM bundles WHERE site_id = $1 +` + +func (q *Queries) ListBundles(ctx context.Context, siteID int64) ([]Bundle, error) { + rows, err := q.db.Query(ctx, listBundles, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Bundle + for rows.Next() { + var i Bundle + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ); 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/models.go b/gen/sqlc/dbq/models.go index d4052a2..9a3d13b 100644 --- a/gen/sqlc/dbq/models.go +++ b/gen/sqlc/dbq/models.go @@ -11,6 +11,49 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type PageNameProvenance string + +const ( + PageNameProvenanceUser PageNameProvenance = "user" + PageNameProvenanceTitle PageNameProvenance = "title" + PageNameProvenanceDate PageNameProvenance = "date" +) + +func (e *PageNameProvenance) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = PageNameProvenance(s) + case string: + *e = PageNameProvenance(s) + default: + return fmt.Errorf("unsupported scan type for PageNameProvenance: %T", src) + } + return nil +} + +type NullPageNameProvenance struct { + PageNameProvenance PageNameProvenance + Valid bool // Valid is true if PageNameProvenance is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPageNameProvenance) Scan(value interface{}) error { + if value == nil { + ns.PageNameProvenance, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.PageNameProvenance.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPageNameProvenance) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.PageNameProvenance), nil +} + type PostState string const ( @@ -135,15 +178,47 @@ func (ns NullTargetType) Value() (driver.Value, error) { return string(ns.TargetType), nil } -type Post struct { +type Bundle struct { ID int64 SiteID int64 - Title pgtype.Text - Body string - State PostState - Props []byte - PostDate pgtype.Timestamptz + Name string CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} + +type Page struct { + ID int64 + SiteID int64 + BundleID int64 + Name string + NameProvenance PageNameProvenance + Title pgtype.Text + Role pgtype.Int8 + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} + +type Post struct { + ID int64 + SiteID int64 + Title pgtype.Text + Role pgtype.Int8 + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} + +type PostRole struct { + ID int64 + SiteID int64 + LayoutName string } type PublishTarget struct { diff --git a/gen/sqlc/dbq/pages.sql.go b/gen/sqlc/dbq/pages.sql.go new file mode 100644 index 0000000..20d4023 --- /dev/null +++ b/gen/sqlc/dbq/pages.sql.go @@ -0,0 +1,233 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: pages.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deletePageWithID = `-- name: DeletePageWithID :exec +DELETE FROM pages WHERE id = $1 +` + +func (q *Queries) DeletePageWithID(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deletePageWithID, id) + return err +} + +const getPageWithID = `-- name: GetPageWithID :one +SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at FROM pages WHERE id = $1 +` + +func (q *Queries) GetPageWithID(ctx context.Context, id int64) (Page, error) { + row := q.db.QueryRow(ctx, getPageWithID, id) + var i Page + err := row.Scan( + &i.ID, + &i.SiteID, + &i.BundleID, + &i.Name, + &i.NameProvenance, + &i.Title, + &i.Role, + &i.Body, + &i.State, + &i.Props, + &i.PublishDate, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertPage = `-- name: InsertPage :one +INSERT INTO pages ( + site_id, + bundle_id, + name, + name_provenance, + title, + role, + body, + state, + props, + publish_date, + created_at, + updated_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) +RETURNING id +` + +type InsertPageParams struct { + SiteID int64 + BundleID int64 + Name string + NameProvenance PageNameProvenance + Title pgtype.Text + Role pgtype.Int8 + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp +} + +func (q *Queries) InsertPage(ctx context.Context, arg InsertPageParams) (int64, error) { + row := q.db.QueryRow(ctx, insertPage, + arg.SiteID, + arg.BundleID, + arg.Name, + arg.NameProvenance, + arg.Title, + arg.Role, + arg.Body, + arg.State, + arg.Props, + arg.PublishDate, + arg.CreatedAt, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const listPages = `-- name: ListPages :many +SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at FROM pages WHERE site_id = $1 ORDER BY name ASC LIMIT 25 +` + +func (q *Queries) ListPages(ctx context.Context, siteID int64) ([]Page, error) { + rows, err := q.db.Query(ctx, listPages, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Page + for rows.Next() { + var i Page + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.BundleID, + &i.Name, + &i.NameProvenance, + &i.Title, + &i.Role, + &i.Body, + &i.State, + &i.Props, + &i.PublishDate, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPublishablePages = `-- name: ListPublishablePages :many +SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at +FROM pages +WHERE id > $1 AND site_id = $2 AND state = 'published' +ORDER BY id LIMIT 100 +` + +type ListPublishablePagesParams struct { + ID int64 + SiteID int64 +} + +func (q *Queries) ListPublishablePages(ctx context.Context, arg ListPublishablePagesParams) ([]Page, error) { + rows, err := q.db.Query(ctx, listPublishablePages, arg.ID, arg.SiteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Page + for rows.Next() { + var i Page + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.BundleID, + &i.Name, + &i.NameProvenance, + &i.Title, + &i.Role, + &i.Body, + &i.State, + &i.Props, + &i.PublishDate, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updatePage = `-- name: UpdatePage :exec +UPDATE pages SET + site_id = $2, + bundle_id = $3, + name = $4, + name_provenance = $5, + title = $6, + role = $7, + body = $8, + state = $9, + props = $10, + publish_date = $11, + created_at = $12, + updated_at = $13 +WHERE id = $1 +` + +type UpdatePageParams struct { + ID int64 + SiteID int64 + BundleID int64 + Name string + NameProvenance PageNameProvenance + Title pgtype.Text + Role pgtype.Int8 + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} + +func (q *Queries) UpdatePage(ctx context.Context, arg UpdatePageParams) error { + _, err := q.db.Exec(ctx, updatePage, + arg.ID, + arg.SiteID, + arg.BundleID, + arg.Name, + arg.NameProvenance, + arg.Title, + arg.Role, + arg.Body, + arg.State, + arg.Props, + arg.PublishDate, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} diff --git a/gen/sqlc/dbq/posts.sql.go b/gen/sqlc/dbq/posts.sql.go index 6724a64..2f55e3a 100644 --- a/gen/sqlc/dbq/posts.sql.go +++ b/gen/sqlc/dbq/posts.sql.go @@ -21,7 +21,7 @@ func (q *Queries) DeletePost(ctx context.Context, id int64) error { } const getPostWithID = `-- name: GetPostWithID :one -SELECT id, site_id, title, body, state, props, post_date, created_at FROM posts WHERE id = $1 LIMIT 1 +SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts WHERE id = $1 LIMIT 1 ` func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) { @@ -31,11 +31,13 @@ func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) { &i.ID, &i.SiteID, &i.Title, + &i.Role, &i.Body, &i.State, &i.Props, - &i.PostDate, + &i.PublishDate, &i.CreatedAt, + &i.UpdatedAt, ) return i, err } @@ -47,20 +49,22 @@ INSERT INTO posts ( body, state, props, - post_date, - created_at -) VALUES ($1, $2, $3, $4, $5, $6, $7) + publish_date, + created_at, + updated_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id ` type InsertPostParams struct { - SiteID int64 - Title pgtype.Text - Body string - State PostState - Props []byte - PostDate pgtype.Timestamptz - CreatedAt pgtype.Timestamp + SiteID int64 + Title pgtype.Text + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp } func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) { @@ -70,8 +74,9 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, arg.Body, arg.State, arg.Props, - arg.PostDate, + arg.PublishDate, arg.CreatedAt, + arg.UpdatedAt, ) var id int64 err := row.Scan(&id) @@ -79,7 +84,7 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, } const listPosts = `-- name: ListPosts :many -SELECT id, site_id, title, body, state, props, post_date, created_at FROM posts WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25 +SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts WHERE site_id = $1 ORDER BY publish_date DESC LIMIT 25 ` func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { @@ -95,11 +100,13 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { &i.ID, &i.SiteID, &i.Title, + &i.Role, &i.Body, &i.State, &i.Props, - &i.PostDate, + &i.PublishDate, &i.CreatedAt, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -112,20 +119,20 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { } const listPublishablePosts = `-- name: ListPublishablePosts :many -SELECT id, site_id, title, body, state, props, post_date, created_at +SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts -WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 +WHERE id > $1 AND site_id = $2 AND state = 'published' AND publish_date <= $3 ORDER BY id LIMIT 100 ` type ListPublishablePostsParams struct { - ID int64 - SiteID int64 - PostDate pgtype.Timestamptz + ID int64 + SiteID int64 + PublishDate 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) + rows, err := q.db.Query(ctx, listPublishablePosts, arg.ID, arg.SiteID, arg.PublishDate) if err != nil { return nil, err } @@ -137,11 +144,13 @@ func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishableP &i.ID, &i.SiteID, &i.Title, + &i.Role, &i.Body, &i.State, &i.Props, - &i.PostDate, + &i.PublishDate, &i.CreatedAt, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -160,19 +169,20 @@ UPDATE posts SET body = $4, state = $5, props = $6, - post_date = $7 - -- updated_at = $7 + publish_date = $7, + updated_at = $8 WHERE id = $1 ` type UpdatePostParams struct { - ID int64 - SiteID int64 - Title pgtype.Text - Body string - State PostState - Props []byte - PostDate pgtype.Timestamptz + ID int64 + SiteID int64 + Title pgtype.Text + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + UpdatedAt pgtype.Timestamp } func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error { @@ -183,7 +193,8 @@ func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error { arg.Body, arg.State, arg.Props, - arg.PostDate, + arg.PublishDate, + arg.UpdatedAt, ) return err } diff --git a/go.mod b/go.mod index d032137..9e10103 100644 --- a/go.mod +++ b/go.mod @@ -48,5 +48,5 @@ require ( golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac // indirect + lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d // indirect ) diff --git a/go.sum b/go.sum index cc84584..fcd8439 100644 --- a/go.sum +++ b/go.sum @@ -99,3 +99,5 @@ lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1 h1:Seqp9vlIw3uJBL0V/eWIM lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac h1:i/C+DYDCVQTQHtv7w1O8m20RMez6YS9fUIlhAGjTZhU= lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= +lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d h1:x5aMBOkCr4cjJyFmq+qJVUsByfffD9k56HYDx1yZSR4= +lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= diff --git a/handlers/page.go b/handlers/page.go new file mode 100644 index 0000000..e9a3f7f --- /dev/null +++ b/handlers/page.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "errors" + "fmt" + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/lmika/hugo-cms/services/pages" + "net/http" +) + +type Pages struct { + Svc *pages.Service +} + +func (h *Pages) Index(c fiber.Ctx) error { + site := GetSite(c) + + pages, err := h.Svc.ListPagesOfSite(c.Context(), site) + if err != nil { + return err + } + + return c.Render("pages/index", fiber.Map{ + "pages": pages, + }, "layouts/site") +} + +func (h *Pages) New(c fiber.Ctx) error { + return c.Render("pages/edit", fiber.Map{ + "page": models.Page{}, + }, "layouts/site") +} + +func (h *Pages) Create(c fiber.Ctx) error { + site := GetSite(c) + + var req struct { + Title string `json:"title" form:"title"` + Body string `json:"body" form:"body"` + } + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err := h.Svc.Create(c.Context(), site, pages.NewPost{ + Title: req.Title, + Body: req.Body, + }) + if err != nil { + return err + } + + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (h *Pages) Edit(c fiber.Ctx) error { + site := GetSite(c) + + pageID := fiber.Params[int](c, "pageId") + if pageID == 0 { + return errors.New("pageId is required") + } + + page, err := h.Svc.GetPage(c.Context(), pageID) + if err != nil { + return err + } else if page.SiteID != site.ID { + return fmt.Errorf("page id %v not equal to site id %v", pageID, site.ID) + } + + return c.Render("pages/edit", fiber.Map{ + "page": page, + }, "layouts/site") +} + +func (h *Pages) Update(c fiber.Ctx) error { + site := GetSite(c) + + pageID := fiber.Params[int](c, "pageId") + if pageID == 0 { + return errors.New("pageId is required") + } + + var req struct { + Title string `json:"title" form:"title"` + Body string `json:"body" form:"body"` + } + if err := c.Bind().Body(&req); err != nil { + return err + } + + if _, err := h.Svc.Update(c.Context(), site, int64(pageID), pages.NewPost{ + Title: req.Title, + Body: req.Body, + }); err != nil { + return err + } + + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (h *Pages) Delete(c fiber.Ctx) error { + site := GetSite(c) + + pageID := fiber.Params[int](c, "pageId") + if pageID == 0 { + return errors.New("pageID is required") + } + + if err := h.Svc.DeletePage(c.Context(), site, pageID); err != nil { + return err + } + + return Select(c, + HTMX(func(c fiber.Ctx) error { + return c.Status(http.StatusOK).SendString("") + }), + Otherwise(func(c fiber.Ctx) error { + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) + }), + ) +} diff --git a/main.go b/main.go index d267d7e..5ff1f4b 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "lmika.dev/lmika/hugo-cms/providers/netlify" "lmika.dev/lmika/hugo-cms/providers/themes" "lmika.dev/lmika/hugo-cms/services/jobs" + "lmika.dev/lmika/hugo-cms/services/pages" "lmika.dev/lmika/hugo-cms/services/posts" "lmika.dev/lmika/hugo-cms/services/sitebuilder" "lmika.dev/lmika/hugo-cms/services/sites" @@ -85,10 +86,12 @@ func main() { siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService) postService := posts.New(dbp, siteBuilderService, jobService) + pageService := pages.New(dbp, siteBuilderService, jobService) indexHandlers := handlers.IndexHandler{} siteHandlers := handlers.Site{Site: siteService, Bus: bus} postHandlers := handlers.Post{Post: postService} + pageHandlers := handlers.Pages{Svc: pageService} authHandlers := handlers.AuthHandler{UserService: userService} tmplEngine := html.NewFileSystem(http.FS(templates.FS), ".html") @@ -137,6 +140,13 @@ func main() { sr.Post("/posts/:postId", postHandlers.Update) sr.Delete("/posts/:postId", postHandlers.Delete) + sr.Get("/pages", pageHandlers.Index) + sr.Get("/pages/new", pageHandlers.New) + sr.Post("/pages", pageHandlers.Create) + sr.Get("/pages/:pageId", pageHandlers.Edit) + sr.Post("/pages/:pageId", pageHandlers.Update) + sr.Delete("/pages/:pageId", pageHandlers.Delete) + sr.Get("/settings", siteHandlers.Settings) sr.Post("/settings", siteHandlers.SaveSettings) sr.Get("/sse", siteHandlers.SSE) diff --git a/models/bundle.go b/models/bundle.go new file mode 100644 index 0000000..2d6a1b1 --- /dev/null +++ b/models/bundle.go @@ -0,0 +1,40 @@ +package models + +import "time" + +const ( + RootBundleName = "_root" +) + +// NameProvenance encodes where the name came from, whether it was set by the user or autogenerated in some way +type NameProvenance int + +const ( + UserNameProvenance NameProvenance = iota + TitleNameProvenance NameProvenance = iota + DateNameProvenance NameProvenance = iota +) + +type Bundle struct { + ID int64 + SiteID int64 + Name string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Page struct { + ID int64 + SiteID int64 + BundleID int64 + Name string + NameProvenance NameProvenance + Title string + Role int64 + Body string + State PostState + Props []byte + PublishDate time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/models/posts.go b/models/posts.go index a95fb36..83bb663 100644 --- a/models/posts.go +++ b/models/posts.go @@ -10,13 +10,13 @@ const ( ) type Post struct { - ID int64 - SiteID int64 - OwnerID int64 - Title string - Body string - State PostState - PostDate time.Time - CreatedAt time.Time - UpdatedAt time.Time + ID int64 + SiteID int64 + OwnerID int64 + Title string + Body string + State PostState + PublishDate time.Time + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/models/theme.go b/models/theme.go index 3b07e0e..e832748 100644 --- a/models/theme.go +++ b/models/theme.go @@ -8,6 +8,6 @@ type ThemeMeta struct { // Indicates that this theme prefers posts have titles. PreferTitle bool - // Content directory for "blog" posts - PostDir string `json:"post_dir"` + // Page bundle for "blog" posts + BlogPostBundle string `json:"post_dir"` } diff --git a/providers/db/bundles.go b/providers/db/bundles.go new file mode 100644 index 0000000..5b724c1 --- /dev/null +++ b/providers/db/bundles.go @@ -0,0 +1,50 @@ +package db + +import ( + "context" + "github.com/jackc/pgx/v5/pgtype" + "lmika.dev/lmika/hugo-cms/gen/sqlc/dbq" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/pkg/modash/moslice" +) + +func (db *DB) InsertBundle(ctx context.Context, bundle *models.Bundle) error { + id, err := db.q.InsertBundle(ctx, dbq.InsertBundleParams{ + SiteID: bundle.SiteID, + Name: bundle.Name, + CreatedAt: pgtype.Timestamp{Time: bundle.CreatedAt, Valid: true}, + }) + if err != nil { + return err + } + bundle.ID = id + return nil +} + +func (db *DB) ListBundles(ctx context.Context, siteID int64) ([]models.Bundle, error) { + res, err := db.q.ListBundles(ctx, siteID) + if err != nil { + return nil, err + } + + return moslice.Map(res, dbBundleToBundle), nil +} + +func (db *DB) GetBundleWithID(ctx context.Context, id int64) (models.Bundle, error) { + res, err := db.q.GetBundleWithID(ctx, id) + if err != nil { + return models.Bundle{}, err + } + + return dbBundleToBundle(res), nil +} + +func dbBundleToBundle(b dbq.Bundle) models.Bundle { + return models.Bundle{ + ID: b.ID, + SiteID: b.SiteID, + Name: b.Name, + CreatedAt: b.CreatedAt.Time, + UpdatedAt: b.UpdatedAt.Time, + } +} diff --git a/providers/db/page.go b/providers/db/page.go new file mode 100644 index 0000000..4b209d4 --- /dev/null +++ b/providers/db/page.go @@ -0,0 +1,105 @@ +package db + +import ( + "context" + "github.com/jackc/pgx/v5/pgtype" + "lmika.dev/lmika/hugo-cms/gen/sqlc/dbq" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/pkg/modash/momap" + "lmika.dev/pkg/modash/moslice" +) + +var nameProvenanceToDBNameProvenance = map[models.NameProvenance]dbq.PageNameProvenance{ + models.UserNameProvenance: dbq.PageNameProvenanceUser, + models.TitleNameProvenance: dbq.PageNameProvenanceTitle, + models.DateNameProvenance: dbq.PageNameProvenanceDate, +} +var dbNameProvenanceToNameProvenance = momap.ReverseMap(nameProvenanceToDBNameProvenance) + +func (db *DB) InsertPage(ctx context.Context, page *models.Page) error { + + id, err := db.q.InsertPage(ctx, dbq.InsertPageParams{ + SiteID: page.SiteID, + BundleID: page.BundleID, + Name: page.Name, + NameProvenance: nameProvenanceToDBNameProvenance[page.NameProvenance], + Title: pgtype.Text{String: page.Title, Valid: page.Title != ""}, + Body: page.Body, + State: dbq.PostState(page.State), + Props: []byte(`{}`), + PublishDate: pgtype.Timestamptz{Time: page.PublishDate, Valid: !page.PublishDate.IsZero()}, + CreatedAt: pgtype.Timestamp{Time: page.CreatedAt, Valid: true}, + }) + if err != nil { + return err + } + page.ID = id + return nil +} + +func (db *DB) UpdatePage(ctx context.Context, page *models.Page) error { + return db.q.UpdatePage(ctx, dbq.UpdatePageParams{ + ID: page.ID, + SiteID: page.SiteID, + BundleID: page.BundleID, + Name: page.Name, + NameProvenance: nameProvenanceToDBNameProvenance[page.NameProvenance], + Title: pgtype.Text{String: page.Title, Valid: page.Title != ""}, + Body: page.Body, + State: dbq.PostState(page.State), + Props: []byte(`{}`), + PublishDate: pgtype.Timestamptz{Time: page.PublishDate, Valid: true}, + CreatedAt: pgtype.Timestamp{Time: page.CreatedAt, Valid: true}, + UpdatedAt: pgtype.Timestamp{Time: page.UpdatedAt, Valid: true}, + }) +} + +func (db *DB) ListPagesOfSite(ctx context.Context, siteID int64) ([]models.Page, error) { + res, err := db.q.ListPages(ctx, siteID) + if err != nil { + return nil, err + } + + return moslice.Map(res, dbPageToPage), nil +} + +func (db *DB) ListPublishablePages(ctx context.Context, fromID, siteID int64) ([]models.Page, error) { + res, err := db.q.ListPublishablePages(ctx, dbq.ListPublishablePagesParams{ + ID: fromID, + SiteID: siteID, + }) + if err != nil { + return nil, err + } + + return moslice.Map(res, dbPageToPage), nil +} + +func (db *DB) GetPage(ctx context.Context, postID int64) (models.Page, error) { + res, err := db.q.GetPageWithID(ctx, postID) + if err != nil { + return models.Page{}, err + } + + return dbPageToPage(res), nil +} + +func (db *DB) DeletePage(ctx context.Context, pageID int64) error { + + return db.q.DeletePageWithID(ctx, pageID) +} + +func dbPageToPage(p dbq.Page) models.Page { + return models.Page{ + ID: p.ID, + SiteID: p.SiteID, + BundleID: p.BundleID, + Name: p.Name, + NameProvenance: dbNameProvenanceToNameProvenance[p.NameProvenance], + Title: p.Title.String, + Body: p.Body, + State: models.PostState(p.State), + PublishDate: p.PublishDate.Time, + CreatedAt: p.CreatedAt.Time, + } +} diff --git a/providers/db/posts.go b/providers/db/posts.go index a647453..45b9924 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -33,9 +33,9 @@ func (db *DB) DeletePost(ctx context.Context, postID int64) error { 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}, + ID: fromID, + SiteID: siteID, + PublishDate: pgtype.Timestamptz{Time: now, Valid: true}, }) if err != nil { return nil, err @@ -46,13 +46,14 @@ func (db *DB) ListPublishablePosts(ctx context.Context, fromID, siteID int64, no func (db *DB) InsertPost(ctx context.Context, p *models.Post) error { res, err := db.q.InsertPost(ctx, dbq.InsertPostParams{ - SiteID: p.SiteID, - Title: pgtype.Text{String: p.Title, Valid: p.Title != ""}, - Body: p.Body, - State: dbq.PostState(p.State), - Props: []byte(`{}`), - PostDate: pgtype.Timestamptz{Time: p.PostDate, Valid: !p.PostDate.IsZero()}, - CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()}, + SiteID: p.SiteID, + Title: pgtype.Text{String: p.Title, Valid: p.Title != ""}, + Body: p.Body, + State: dbq.PostState(p.State), + Props: []byte(`{}`), + PublishDate: pgtype.Timestamptz{Time: p.PublishDate, Valid: !p.PublishDate.IsZero()}, + CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()}, + UpdatedAt: pgtype.Timestamp{Time: p.UpdatedAt, Valid: !p.UpdatedAt.IsZero()}, }) if err != nil { return err @@ -64,25 +65,25 @@ func (db *DB) InsertPost(ctx context.Context, p *models.Post) error { func (db *DB) UpdatePost(ctx context.Context, p *models.Post) error { return db.q.UpdatePost(ctx, dbq.UpdatePostParams{ - ID: p.ID, - SiteID: p.SiteID, - Title: pgtype.Text{String: p.Title, Valid: p.Title != ""}, - Body: p.Body, - State: dbq.PostState(p.State), - Props: []byte(`{}`), - PostDate: pgtype.Timestamptz{Time: p.PostDate, Valid: !p.PostDate.IsZero()}, - //CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()}, + ID: p.ID, + SiteID: p.SiteID, + Title: pgtype.Text{String: p.Title, Valid: p.Title != ""}, + Body: p.Body, + State: dbq.PostState(p.State), + Props: []byte(`{}`), + PublishDate: pgtype.Timestamptz{Time: p.PublishDate, Valid: !p.PublishDate.IsZero()}, + UpdatedAt: pgtype.Timestamp{Time: p.UpdatedAt, Valid: !p.UpdatedAt.IsZero()}, }) } 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, + ID: p.ID, + SiteID: p.SiteID, + Title: p.Title.String, + Body: p.Body, + State: models.PostState(p.State), + PublishDate: p.PublishDate.Time, + CreatedAt: p.CreatedAt.Time, } } diff --git a/providers/themes/meta.go b/providers/themes/meta.go index c0a48a2..c2bf058 100644 --- a/providers/themes/meta.go +++ b/providers/themes/meta.go @@ -4,24 +4,24 @@ import "lmika.dev/lmika/hugo-cms/models" var themes = []models.ThemeMeta{ { - ID: "bear", - Name: "Bear", - URL: "https://github.com/janraasch/hugo-bearblog", - PreferTitle: true, - PostDir: "blog", + ID: "bear", + Name: "Bear", + URL: "https://github.com/janraasch/hugo-bearblog", + PreferTitle: true, + BlogPostBundle: "blog", }, { - ID: "terminal", - Name: "Terminal", - URL: "https://github.com/panr/hugo-theme-terminal", - PreferTitle: true, - PostDir: "posts", + ID: "terminal", + Name: "Terminal", + URL: "https://github.com/panr/hugo-theme-terminal", + PreferTitle: true, + BlogPostBundle: "posts", }, { - ID: "yingyang", - Name: "Yingyang", - URL: "https://github.com/joway/hugo-theme-yinyang", - PreferTitle: true, - PostDir: "posts", + ID: "yingyang", + Name: "Yingyang", + URL: "https://github.com/joway/hugo-theme-yinyang", + PreferTitle: true, + BlogPostBundle: "posts", }, } diff --git a/services/pages/services.go b/services/pages/services.go new file mode 100644 index 0000000..82df64d --- /dev/null +++ b/services/pages/services.go @@ -0,0 +1,183 @@ +package pages + +import ( + "context" + "errors" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/lmika/hugo-cms/providers/db" + "lmika.dev/lmika/hugo-cms/services/jobs" + "lmika.dev/lmika/hugo-cms/services/sitebuilder" + "lmika.dev/pkg/modash/moslice" + "strings" + "time" + "unicode" +) + +type Service struct { + db *db.DB + sb *sitebuilder.Service + jobs *jobs.Service +} + +func New( + db *db.DB, + sb *sitebuilder.Service, + jobs *jobs.Service, +) *Service { + return &Service{ + db: db, + sb: sb, + jobs: jobs, + } +} + +func (s *Service) ListPagesOfSite(ctx context.Context, site models.Site) ([]models.Page, error) { + return s.db.ListPagesOfSite(ctx, site.ID) +} + +func (s *Service) GetPage(ctx context.Context, id int) (models.Page, error) { + post, err := s.db.GetPage(ctx, int64(id)) + if err != nil { + return models.Page{}, err + } + + return post, nil +} + +func (s *Service) DeletePage(ctx context.Context, site models.Site, id int) error { + post, err := s.db.GetPage(ctx, int64(id)) + if err != nil { + return err + } + + if err := s.db.DeletePage(ctx, int64(id)); err != nil { + return err + } + + return s.jobs.Queue(ctx, s.sb.DeletePage(site, post)) +} + +func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (models.Page, error) { + siteBundles, err := s.db.ListBundles(ctx, site.ID) + if err != nil { + return models.Page{}, err + } else if len(siteBundles) == 0 { + return models.Page{}, errors.New("no bundles found") + } + + rootBundle, ok := moslice.FindWhere(siteBundles, func(t models.Bundle) bool { + return t.Name == models.RootBundleName + }) + if !ok { + return models.Page{}, errors.New("root bundle not found") + } + + publishTime := time.Now() + + name := s.normalizePageName(req.Title) + nameProvenance := models.TitleNameProvenance + if name == "" { + // Use the timestamp as the name + name = publishTime.Format("2006-01-02-150405") + nameProvenance = models.DateNameProvenance + } + + post := models.Page{ + SiteID: site.ID, + BundleID: rootBundle.ID, + Name: s.normalizePageName(req.Title), + NameProvenance: nameProvenance, + Title: req.Title, + Body: req.Body, + State: models.PostStatePublished, + PublishDate: time.Now(), + } + + if err := s.save(ctx, site, rootBundle, &post); err != nil { + return models.Page{}, err + } + + return post, nil +} + +func (s *Service) Update(ctx context.Context, site models.Site, pageID int64, req NewPost) (models.Page, error) { + page, err := s.db.GetPage(ctx, pageID) + if err != nil { + return models.Page{}, err + } + + if page.SiteID != site.ID { + return models.Page{}, errors.New("page not found") + } + + bundle, err := s.db.GetBundleWithID(ctx, page.BundleID) + if err != nil { + return models.Page{}, err + } else if bundle.SiteID != site.ID { + return models.Page{}, errors.New("page not found") + } + + // Update the title if it wasn't set by the user + if page.NameProvenance != models.UserNameProvenance { + if req.Title == "" { + page.Name = page.PublishDate.Format("2006-01-02-150405") + page.NameProvenance = models.DateNameProvenance + } else { + page.Name = s.normalizePageName(req.Title) + page.NameProvenance = models.TitleNameProvenance + } + } + page.Title = req.Title + page.Body = req.Body + + if err := s.save(ctx, site, bundle, &page); err != nil { + return models.Page{}, err + } + + return page, nil +} + +func (s *Service) save(ctx context.Context, site models.Site, bundle models.Bundle, page *models.Page) error { + page.SiteID = site.ID + + if page.ID == 0 { + page.CreatedAt = time.Now() + page.UpdatedAt = time.Now() + if err := s.db.InsertPage(ctx, page); err != nil { + return err + } + } else { + page.UpdatedAt = time.Now() + if err := s.db.UpdatePage(ctx, page); err != nil { + return err + } + } + + return s.jobs.Queue(ctx, s.sb.WritePage(site, bundle, *page)) +} + +func (s *Service) normalizePageName(title string) string { + var sb strings.Builder + lastSpace := false + for _, r := range title { + switch { + case unicode.IsSpace(r): + if !lastSpace { + sb.WriteRune('-') + lastSpace = true + } + case unicode.IsNumber(r): + lastSpace = false + sb.WriteRune(r) + case unicode.IsLetter(r): + lastSpace = false + sb.WriteRune(unicode.ToLower(r)) + } + } + return sb.String() +} + +type NewPost struct { + Title string + Body string +} diff --git a/services/posts/services.go b/services/posts/services.go index 902c14c..cecb0b9 100644 --- a/services/posts/services.go +++ b/services/posts/services.go @@ -55,11 +55,11 @@ func (s *Service) DeletePost(ctx context.Context, site models.Site, id int) erro func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (models.Post, error) { post := models.Post{ - SiteID: site.ID, - Title: req.Title, - Body: req.Body, - State: models.PostStatePublished, - PostDate: time.Now(), + SiteID: site.ID, + Title: req.Title, + Body: req.Body, + State: models.PostStatePublished, + PublishDate: time.Now(), } if err := s.Save(ctx, site, &post); err != nil { diff --git a/services/sitebuilder/pages.go b/services/sitebuilder/pages.go new file mode 100644 index 0000000..f551d76 --- /dev/null +++ b/services/sitebuilder/pages.go @@ -0,0 +1,105 @@ +package sitebuilder + +import ( + "context" + "fmt" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/pkg/modash/momap" + "os" + "time" +) + +func (s *Service) WritePage(site models.Site, bundle models.Bundle, page models.Page) models.Job { + return models.Job{ + Do: func(ctx context.Context) error { + s.signalSiteBuildingStarted(ctx, site) + defer s.signalSiteBuildingFinished(ctx, site) + + rbn, err := s.fullRebuildNecessary(ctx, site) + if err != nil { + return err + } else if rbn { + return s.rebuildSite(ctx, site, site) + } + + themeMeta, ok := s.themes.Lookup(site.Theme) + if !ok { + return fmt.Errorf("theme %s not found in themes", site.Theme) + } + + if err := s.writePage(site, themeMeta, bundle, page); err != nil { + return err + } + return s.publish(ctx, site) + }, + } +} + +func (s *Service) DeletePage(site models.Site, page models.Page) models.Job { + return models.Job{ + Do: func(ctx context.Context) error { + s.signalSiteBuildingStarted(ctx, site) + defer s.signalSiteBuildingFinished(ctx, site) + + bundle, err := s.db.GetBundleWithID(ctx, page.BundleID) + if err != nil { + return err + } + + postFilename := s.pageFilename(site, bundle, page) + + if os.Remove(postFilename) != nil { + return nil + } + // TODO: if dir is empty, delete it + + return s.publish(ctx, site) + }, + } +} + +func (s *Service) writeAllPages(ctx context.Context, site models.Site) error { + themeMeta, ok := s.themes.Lookup(site.Theme) + if !ok { + return fmt.Errorf("theme %s not found in themes", site.Theme) + } + + bundles, err := s.db.ListBundles(ctx, site.ID) + if err != nil { + return err + } + + bundlesByID := momap.FromSlice(bundles, func(b models.Bundle) (int64, models.Bundle) { return b.ID, b }) + + var startId int64 + for { + pages, err := s.db.ListPublishablePages(ctx, int64(startId), site.ID) + if err != nil { + return err + } else if len(pages) == 0 { + return nil + } + + for _, page := range pages { + if err := s.writePage(site, themeMeta, bundlesByID[page.BundleID], page); err != nil { + return err + } + } + startId = pages[len(pages)-1].ID + } +} + +func (s *Service) writePage(site models.Site, themeMeta models.ThemeMeta, bundle models.Bundle, page models.Page) error { + postFilename := s.pageFilename(site, bundle, page) + + frontMatter := map[string]any{ + "date": page.PublishDate.Format(time.RFC3339), + } + if page.Title != "" { + frontMatter["title"] = page.Title + } else if themeMeta.PreferTitle { + frontMatter["title"] = page.PublishDate.Format(time.ANSIC) + } + + return s.writeMarkdownFile(postFilename, frontMatter, page.Body) +} diff --git a/services/sitebuilder/posts.go b/services/sitebuilder/posts.go index 72ac747..9789092 100644 --- a/services/sitebuilder/posts.go +++ b/services/sitebuilder/posts.go @@ -6,7 +6,6 @@ import ( "gopkg.in/yaml.v3" "lmika.dev/lmika/hugo-cms/models" "lmika.dev/lmika/hugo-cms/providers/hugo" - "log" "os" "path/filepath" "time" @@ -104,19 +103,21 @@ func (s *Service) writePost(site models.Site, post models.Post) error { postFilename := s.postFilename(site, themeMeta, post) - 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), + frontMatter := map[string]any{ + "date": post.PublishDate.Format(time.RFC3339), } if post.Title != "" { frontMatter["title"] = post.Title } else if themeMeta.PreferTitle { - frontMatter["title"] = post.PostDate.Format(time.ANSIC) + frontMatter["title"] = post.PublishDate.Format(time.ANSIC) + } + + return s.writeMarkdownFile(postFilename, frontMatter, post.Body) +} + +func (s *Service) writeMarkdownFile(outFile string, frontMatter map[string]any, body string) error { + if err := os.MkdirAll(filepath.Dir(outFile), 0755); err != nil { + return err } fmBytes, err := yaml.Marshal(frontMatter) @@ -124,7 +125,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error { return err } - f, err := os.Create(postFilename) + f, err := os.Create(outFile) if err != nil { return err } @@ -139,7 +140,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error { if _, err := f.WriteString("---\n"); err != nil { return err } - if _, err := f.WriteString(post.Body); err != nil { + if _, err := f.WriteString(body); err != nil { return err } @@ -147,5 +148,15 @@ func (s *Service) writePost(site models.Site, post models.Post) error { } func (s *Service) postFilename(site models.Site, themeMeta models.ThemeMeta, post models.Post) string { - return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md")) + return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.BlogPostBundle, post.CreatedAt.Format("2006-01-02-150405.md")) +} + +func (s *Service) pageFilename(site models.Site, bundle models.Bundle, page models.Page) string { + bundleDir := "" + if bundle.Name != models.RootBundleName { + bundleDir = bundle.Name + } + + pageName := page.Name + ".md" + return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), bundleDir, pageName) } diff --git a/services/sitebuilder/service.go b/services/sitebuilder/service.go index 914f901..b7c7faa 100644 --- a/services/sitebuilder/service.go +++ b/services/sitebuilder/service.go @@ -79,6 +79,10 @@ func (s *Service) rebuildSite(ctx context.Context, oldSite, newSite models.Site) return err } + if err := s.writeAllPages(ctx, newSite); err != nil { + return err + } + return s.publish(ctx, newSite) } @@ -148,3 +152,4 @@ func (s *Service) signalSiteBuildingStarted(ctx context.Context, site models.Sit func (s *Service) signalSiteBuildingFinished(ctx context.Context, site models.Site) { s.bus.Fire(models.Event{Type: models.EventSiteBuildingDone, Data: site}) } + diff --git a/services/sitebuilder/tracker.go b/services/sitebuilder/tracker.go new file mode 100644 index 0000000..51204a7 --- /dev/null +++ b/services/sitebuilder/tracker.go @@ -0,0 +1,32 @@ +package sitebuilder + +import ( + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/lmika/hugo-cms/providers/bus" +) + +type SiteBuildingTracker struct { + bus *bus.Bus + isBuildingState map[int64]models.Site +} + +func NewSiteBuildingTracker(bus *bus.Bus) *SiteBuildingTracker { + return &SiteBuildingTracker{ + bus: bus, + isBuildingState: map[int64]models.Site{}, + } +} + +func (sbt *SiteBuildingTracker) Listen() { + sub := sbt.bus.Subscribe() + + for e := range sub.C { + switch e.Type { + case models.EventSiteBuildingStart: + site := e.Data.(models.Site) + sbt.isBuildingState[site.ID] = site + case models.EventSiteBuildingDone: + delete(sbt.isBuildingState, e.Data.(models.Site).ID) + } + } +} diff --git a/services/sites/create.go b/services/sites/create.go new file mode 100644 index 0000000..b8a679d --- /dev/null +++ b/services/sites/create.go @@ -0,0 +1,55 @@ +package sites + +import ( + "context" + "errors" + "lmika.dev/lmika/hugo-cms/models" + "time" +) + +func (s *Service) CreateSite(ctx context.Context, user models.User, name string) (models.Site, error) { + // Create a new site + newSite := models.Site{ + Name: normaliseName(name), + OwnerUserID: user.ID, + Title: name, + Theme: "bear", + } + + _, 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 + } + + // Add the default page bundle + rootBundle := models.Bundle{ + SiteID: newSite.ID, + Name: models.RootBundleName, + CreatedAt: time.Now(), + } + if err := s.db.InsertBundle(ctx, &rootBundle); err != nil { + return models.Site{}, err + } + + // TEMP: Add a home page + homePage := models.Page{ + SiteID: newSite.ID, + BundleID: rootBundle.ID, + Name: "index", + Title: "Welcome to the home page", + Body: "This is the home page", + State: models.PostStatePublished, + PublishDate: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.db.InsertPage(ctx, &homePage); err != nil { + return models.Site{}, err + } + + return newSite, s.jobs.Queue(ctx, s.sb.RebuildSite(newSite, newSite)) +} diff --git a/services/sites/service.go b/services/sites/service.go index 8848b29..9380ff1 100644 --- a/services/sites/service.go +++ b/services/sites/service.go @@ -46,27 +46,6 @@ func (s *Service) GetProdTargetOfSite(ctx context.Context, siteID int) (models.P return s.db.GetPublishTargetBySiteRole(ctx, int64(siteID), models.TargetRoleProduction) } -func (s *Service) CreateSite(ctx context.Context, user models.User, name string) (models.Site, error) { - newSite := models.Site{ - Name: normaliseName(name), - OwnerUserID: user.ID, - Title: name, - Theme: "bear", - //Theme: "yingyang", - } - - _, 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 - } - - return newSite, s.jobs.Queue(ctx, s.sb.CreateNewSite(newSite)) -} - func (s *Service) SaveSettings(ctx context.Context, site models.Site, newSettings NewSettings) error { _, ok := s.themes.Lookup(newSettings.SiteTheme) if !ok { diff --git a/sql/queries/bundles.sql b/sql/queries/bundles.sql new file mode 100644 index 0000000..6d2a497 --- /dev/null +++ b/sql/queries/bundles.sql @@ -0,0 +1,13 @@ +-- name: InsertBundle :one +INSERT INTO bundles ( + site_id, + name, + created_at, + updated_at +) VALUES ($1, $2, $3, $3) RETURNING id; + +-- name: ListBundles :many +SELECT * FROM bundles WHERE site_id = $1; + +-- name: GetBundleWithID :one +SELECT * FROM bundles WHERE id = $1; \ No newline at end of file diff --git a/sql/queries/pages.sql b/sql/queries/pages.sql new file mode 100644 index 0000000..d3b77db --- /dev/null +++ b/sql/queries/pages.sql @@ -0,0 +1,47 @@ +-- name: InsertPage :one +INSERT INTO pages ( + site_id, + bundle_id, + name, + name_provenance, + title, + role, + body, + state, + props, + publish_date, + created_at, + updated_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) +RETURNING id; + +-- name: UpdatePage :exec +UPDATE pages SET + site_id = $2, + bundle_id = $3, + name = $4, + name_provenance = $5, + title = $6, + role = $7, + body = $8, + state = $9, + props = $10, + publish_date = $11, + created_at = $12, + updated_at = $13 +WHERE id = $1; + +-- name: ListPublishablePages :many +SELECT * +FROM pages +WHERE id > $1 AND site_id = $2 AND state = 'published' +ORDER BY id LIMIT 100; + +-- name: ListPages :many +SELECT * FROM pages WHERE site_id = $1 ORDER BY name ASC LIMIT 25; + +-- name: GetPageWithID :one +SELECT * FROM pages WHERE id = $1; + +-- name: DeletePageWithID :exec +DELETE FROM pages WHERE id = $1; \ No newline at end of file diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 1ddf3cf..f33bbfa 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,5 +1,5 @@ -- name: ListPosts :many -SELECT * FROM posts WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25; +SELECT * FROM posts WHERE site_id = $1 ORDER BY publish_date DESC LIMIT 25; -- name: GetPostWithID :one SELECT * FROM posts WHERE id = $1 LIMIT 1; @@ -7,7 +7,7 @@ SELECT * FROM posts WHERE id = $1 LIMIT 1; -- name: ListPublishablePosts :many SELECT * FROM posts -WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 +WHERE id > $1 AND site_id = $2 AND state = 'published' AND publish_date <= $3 ORDER BY id LIMIT 100; -- name: InsertPost :one @@ -17,9 +17,10 @@ INSERT INTO posts ( body, state, props, - post_date, - created_at -) VALUES ($1, $2, $3, $4, $5, $6, $7) + publish_date, + created_at, + updated_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id; -- name: UpdatePost :exec @@ -29,8 +30,8 @@ UPDATE posts SET body = $4, state = $5, props = $6, - post_date = $7 - -- updated_at = $7 + publish_date = $7, + updated_at = $8 WHERE id = $1; -- name: DeletePost :exec diff --git a/sql/schema/1_init.up.sql b/sql/schema/1_init.up.sql index 38492d1..6524422 100644 --- a/sql/schema/1_init.up.sql +++ b/sql/schema/1_init.up.sql @@ -11,6 +11,12 @@ CREATE TYPE target_type AS ENUM ( 'netlify' ); +CREATE TYPE page_name_provenance AS ENUM ( + 'user', + 'title', + 'date' +); + CREATE TABLE users ( id BIGSERIAL NOT NULL PRIMARY KEY, email TEXT NOT NULL UNIQUE, @@ -28,19 +34,63 @@ CREATE TABLE sites ( FOREIGN KEY (owner_user_id) REFERENCES users (id) ); -CREATE TABLE posts ( - id BIGSERIAL NOT NULL PRIMARY KEY, - site_id BIGINT NOT NULL, - title TEXT, - body TEXT NOT NULL, - state post_state NOT NULL, - props JSON NOT NULL, - post_date TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP NOT NULL, +-- Post role is used to describe a specific kind of post, such as a link. +-- When set, it specifies the layout to use for the page +CREATE TABLE post_roles ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + layout_name TEXT NOT NULL, FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE ); +CREATE TABLE posts ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + title TEXT, + role BIGINT, + body TEXT NOT NULL, + state post_state NOT NULL, + props JSON NOT NULL, + publish_date TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + FOREIGN KEY (role) REFERENCES post_roles (id) ON DELETE CASCADE, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); + +CREATE TABLE bundles ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); + +CREATE TABLE pages ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + bundle_id BIGINT NOT NULL, + name TEXT NOT NULL, + name_provenance page_name_provenance NOT NULL, + title TEXT, + role BIGINT, + body TEXT NOT NULL, + state post_state NOT NULL, + props JSON NOT NULL, + publish_date TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + UNIQUE (bundle_id, name), + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE, + FOREIGN KEY (role) REFERENCES post_roles (id) ON DELETE CASCADE, + FOREIGN KEY (bundle_id) REFERENCES sites (id) ON DELETE CASCADE +); + CREATE TABLE publish_targets ( id BIGSERIAL NOT NULL PRIMARY KEY, site_id BIGINT NOT NULL, diff --git a/templates/fs.go b/templates/fs.go index a8b1117..9fa97a7 100644 --- a/templates/fs.go +++ b/templates/fs.go @@ -6,5 +6,6 @@ import "embed" //go:embed auth/*.html //go:embed layouts/*.html //go:embed posts/*.html +//go:embed pages/*.html //go:embed sites/*.html var FS embed.FS diff --git a/templates/pages/edit.html b/templates/pages/edit.html new file mode 100644 index 0000000..608201c --- /dev/null +++ b/templates/pages/edit.html @@ -0,0 +1,13 @@ +{{- $postTarget := printf "/sites/%v/pages" .site.ID -}} +{{- if (ne .page.ID 0) -}} + {{- $postTarget = printf "/sites/%v/pages/%v" .site.ID .page.ID -}} +{{- end -}} +
+ + + + +
+ +
+
diff --git a/templates/pages/index.html b/templates/pages/index.html new file mode 100644 index 0000000..acc7262 --- /dev/null +++ b/templates/pages/index.html @@ -0,0 +1,20 @@ +
+ New Page +
+ +{{range .pages}} +
+ {{if .Title}} +

{{.Title}}

+ {{end}} + + {{.Body | markdown}} + +
+ Edit | + Delete +
+
+{{else}} +

No pages yet

+{{end}} \ No newline at end of file From 573517565da10993ae9d3fad643eafaee983adaf Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 16 Feb 2025 14:06:45 +1100 Subject: [PATCH 2/5] Made some changes to how index pages are made --- gen/sqlc/dbq/bundles.sql.go | 35 +++++++++++++++++++++++ gen/sqlc/dbq/models.go | 48 +++++++++++++++++++++++++++++-- gen/sqlc/dbq/pages.sql.go | 45 +++++++++++++++++------------ gen/sqlc/dbq/posts.sql.go | 12 ++++---- models/{bundle.go => page.go} | 16 ++++++++++- providers/db/bundles.go | 15 ++++++++++ providers/db/page.go | 9 ++++++ services/pages/services.go | 2 +- services/sitebuilder/pages.go | 54 +++++++++++++++++++++++++++++++---- services/sitebuilder/posts.go | 32 +++++++++++++++++---- services/sites/create.go | 1 + sql/queries/bundles.sql | 10 ++++++- sql/queries/pages.sql | 20 +++++++------ sql/schema/1_init.up.sql | 16 +++++++---- 14 files changed, 259 insertions(+), 56 deletions(-) rename models/{bundle.go => page.go} (78%) diff --git a/gen/sqlc/dbq/bundles.sql.go b/gen/sqlc/dbq/bundles.sql.go index d6f11d8..d98eb37 100644 --- a/gen/sqlc/dbq/bundles.sql.go +++ b/gen/sqlc/dbq/bundles.sql.go @@ -28,6 +28,41 @@ func (q *Queries) GetBundleWithID(ctx context.Context, id int64) (Bundle, error) return i, err } +const getSiteBundleInfo = `-- name: GetSiteBundleInfo :many +WITH page_counts AS ( + SELECT b.bundle_id, count(*) AS page_count FROM pages b WHERE b.site_id = $1 GROUP BY bundle_id +), index_pages AS ( + SELECT p.id AS index_page_id, p.bundle_id FROM pages p WHERE p.site_id = $1 AND p.role = 'index' +) +SELECT b.bundle_id, b.page_count, p.index_page_id FROM page_counts b LEFT OUTER JOIN index_pages p ON b.bundle_id = p.bundle_id +` + +type GetSiteBundleInfoRow struct { + BundleID int64 + PageCount int64 + IndexPageID pgtype.Int8 +} + +func (q *Queries) GetSiteBundleInfo(ctx context.Context, siteID int64) ([]GetSiteBundleInfoRow, error) { + rows, err := q.db.Query(ctx, getSiteBundleInfo, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSiteBundleInfoRow + for rows.Next() { + var i GetSiteBundleInfoRow + if err := rows.Scan(&i.BundleID, &i.PageCount, &i.IndexPageID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertBundle = `-- name: InsertBundle :one INSERT INTO bundles ( site_id, diff --git a/gen/sqlc/dbq/models.go b/gen/sqlc/dbq/models.go index 9a3d13b..e027f2d 100644 --- a/gen/sqlc/dbq/models.go +++ b/gen/sqlc/dbq/models.go @@ -54,6 +54,47 @@ func (ns NullPageNameProvenance) Value() (driver.Value, error) { return string(ns.PageNameProvenance), nil } +type PageRole string + +const ( + PageRoleIndex PageRole = "index" +) + +func (e *PageRole) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = PageRole(s) + case string: + *e = PageRole(s) + default: + return fmt.Errorf("unsupported scan type for PageRole: %T", src) + } + return nil +} + +type NullPageRole struct { + PageRole PageRole + Valid bool // Valid is true if PageRole is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPageRole) Scan(value interface{}) error { + if value == nil { + ns.PageRole, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.PageRole.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPageRole) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.PageRole), nil +} + type PostState string const ( @@ -193,10 +234,11 @@ type Page struct { Name string NameProvenance PageNameProvenance Title pgtype.Text - Role pgtype.Int8 + PostTypeID pgtype.Int8 Body string State PostState Props []byte + Role NullPageRole PublishDate pgtype.Timestamptz CreatedAt pgtype.Timestamp UpdatedAt pgtype.Timestamp @@ -206,7 +248,7 @@ type Post struct { ID int64 SiteID int64 Title pgtype.Text - Role pgtype.Int8 + PostTypeID pgtype.Int8 Body string State PostState Props []byte @@ -215,7 +257,7 @@ type Post struct { UpdatedAt pgtype.Timestamp } -type PostRole struct { +type PostType struct { ID int64 SiteID int64 LayoutName string diff --git a/gen/sqlc/dbq/pages.sql.go b/gen/sqlc/dbq/pages.sql.go index 20d4023..89c3376 100644 --- a/gen/sqlc/dbq/pages.sql.go +++ b/gen/sqlc/dbq/pages.sql.go @@ -21,7 +21,7 @@ func (q *Queries) DeletePageWithID(ctx context.Context, id int64) error { } const getPageWithID = `-- name: GetPageWithID :one -SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at FROM pages WHERE id = $1 +SELECT id, site_id, bundle_id, name, name_provenance, title, post_type_id, body, state, props, role, publish_date, created_at, updated_at FROM pages WHERE id = $1 ` func (q *Queries) GetPageWithID(ctx context.Context, id int64) (Page, error) { @@ -34,10 +34,11 @@ func (q *Queries) GetPageWithID(ctx context.Context, id int64) (Page, error) { &i.Name, &i.NameProvenance, &i.Title, - &i.Role, + &i.PostTypeID, &i.Body, &i.State, &i.Props, + &i.Role, &i.PublishDate, &i.CreatedAt, &i.UpdatedAt, @@ -52,14 +53,15 @@ INSERT INTO pages ( name, name_provenance, title, - role, + post_type_id, body, state, props, + role, publish_date, created_at, updated_at -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12) RETURNING id ` @@ -69,10 +71,11 @@ type InsertPageParams struct { Name string NameProvenance PageNameProvenance Title pgtype.Text - Role pgtype.Int8 + PostTypeID pgtype.Int8 Body string State PostState Props []byte + Role NullPageRole PublishDate pgtype.Timestamptz CreatedAt pgtype.Timestamp } @@ -84,10 +87,11 @@ func (q *Queries) InsertPage(ctx context.Context, arg InsertPageParams) (int64, arg.Name, arg.NameProvenance, arg.Title, - arg.Role, + arg.PostTypeID, arg.Body, arg.State, arg.Props, + arg.Role, arg.PublishDate, arg.CreatedAt, ) @@ -97,7 +101,7 @@ func (q *Queries) InsertPage(ctx context.Context, arg InsertPageParams) (int64, } const listPages = `-- name: ListPages :many -SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at FROM pages WHERE site_id = $1 ORDER BY name ASC LIMIT 25 +SELECT id, site_id, bundle_id, name, name_provenance, title, post_type_id, body, state, props, role, publish_date, created_at, updated_at FROM pages WHERE site_id = $1 ORDER BY name ASC LIMIT 25 ` func (q *Queries) ListPages(ctx context.Context, siteID int64) ([]Page, error) { @@ -116,10 +120,11 @@ func (q *Queries) ListPages(ctx context.Context, siteID int64) ([]Page, error) { &i.Name, &i.NameProvenance, &i.Title, - &i.Role, + &i.PostTypeID, &i.Body, &i.State, &i.Props, + &i.Role, &i.PublishDate, &i.CreatedAt, &i.UpdatedAt, @@ -135,7 +140,7 @@ func (q *Queries) ListPages(ctx context.Context, siteID int64) ([]Page, error) { } const listPublishablePages = `-- name: ListPublishablePages :many -SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at +SELECT id, site_id, bundle_id, name, name_provenance, title, post_type_id, body, state, props, role, publish_date, created_at, updated_at FROM pages WHERE id > $1 AND site_id = $2 AND state = 'published' ORDER BY id LIMIT 100 @@ -162,10 +167,11 @@ func (q *Queries) ListPublishablePages(ctx context.Context, arg ListPublishableP &i.Name, &i.NameProvenance, &i.Title, - &i.Role, + &i.PostTypeID, &i.Body, &i.State, &i.Props, + &i.Role, &i.PublishDate, &i.CreatedAt, &i.UpdatedAt, @@ -187,13 +193,14 @@ UPDATE pages SET name = $4, name_provenance = $5, title = $6, - role = $7, - body = $8, - state = $9, - props = $10, - publish_date = $11, - created_at = $12, - updated_at = $13 + post_type_id = $7, + role = $8, + body = $9, + state = $10, + props = $11, + publish_date = $12, + created_at = $13, + updated_at = $14 WHERE id = $1 ` @@ -204,7 +211,8 @@ type UpdatePageParams struct { Name string NameProvenance PageNameProvenance Title pgtype.Text - Role pgtype.Int8 + PostTypeID pgtype.Int8 + Role NullPageRole Body string State PostState Props []byte @@ -221,6 +229,7 @@ func (q *Queries) UpdatePage(ctx context.Context, arg UpdatePageParams) error { arg.Name, arg.NameProvenance, arg.Title, + arg.PostTypeID, arg.Role, arg.Body, arg.State, diff --git a/gen/sqlc/dbq/posts.sql.go b/gen/sqlc/dbq/posts.sql.go index 2f55e3a..0500cce 100644 --- a/gen/sqlc/dbq/posts.sql.go +++ b/gen/sqlc/dbq/posts.sql.go @@ -21,7 +21,7 @@ func (q *Queries) DeletePost(ctx context.Context, id int64) error { } const getPostWithID = `-- name: GetPostWithID :one -SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts WHERE id = $1 LIMIT 1 +SELECT id, site_id, title, post_type_id, body, state, props, publish_date, created_at, updated_at FROM posts WHERE id = $1 LIMIT 1 ` func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) { @@ -31,7 +31,7 @@ func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) { &i.ID, &i.SiteID, &i.Title, - &i.Role, + &i.PostTypeID, &i.Body, &i.State, &i.Props, @@ -84,7 +84,7 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, } const listPosts = `-- name: ListPosts :many -SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts WHERE site_id = $1 ORDER BY publish_date DESC LIMIT 25 +SELECT id, site_id, title, post_type_id, body, state, props, publish_date, created_at, updated_at FROM posts WHERE site_id = $1 ORDER BY publish_date DESC LIMIT 25 ` func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { @@ -100,7 +100,7 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { &i.ID, &i.SiteID, &i.Title, - &i.Role, + &i.PostTypeID, &i.Body, &i.State, &i.Props, @@ -119,7 +119,7 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { } const listPublishablePosts = `-- name: ListPublishablePosts :many -SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at +SELECT id, site_id, title, post_type_id, body, state, props, publish_date, created_at, updated_at FROM posts WHERE id > $1 AND site_id = $2 AND state = 'published' AND publish_date <= $3 ORDER BY id LIMIT 100 @@ -144,7 +144,7 @@ func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishableP &i.ID, &i.SiteID, &i.Title, - &i.Role, + &i.PostTypeID, &i.Body, &i.State, &i.Props, diff --git a/models/bundle.go b/models/page.go similarity index 78% rename from models/bundle.go rename to models/page.go index 2d6a1b1..d0bb592 100644 --- a/models/bundle.go +++ b/models/page.go @@ -15,6 +15,13 @@ const ( DateNameProvenance NameProvenance = iota ) +type PageRole int + +const ( + NormalPageRole PageRole = iota + IndexPageRole +) + type Bundle struct { ID int64 SiteID int64 @@ -30,11 +37,18 @@ type Page struct { Name string NameProvenance NameProvenance Title string - Role int64 + Role PageRole Body string State PostState + PageTypeID int64 Props []byte PublishDate time.Time CreatedAt time.Time UpdatedAt time.Time } + +type BundleInfo struct { + BundleID int64 + PageCount int + IndexPageID int64 +} diff --git a/providers/db/bundles.go b/providers/db/bundles.go index 5b724c1..730a29e 100644 --- a/providers/db/bundles.go +++ b/providers/db/bundles.go @@ -5,6 +5,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "lmika.dev/lmika/hugo-cms/gen/sqlc/dbq" "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/pkg/modash/momap" "lmika.dev/pkg/modash/moslice" ) @@ -39,6 +40,20 @@ func (db *DB) GetBundleWithID(ctx context.Context, id int64) (models.Bundle, err return dbBundleToBundle(res), nil } +func (db *DB) GetSiteBundleInfo(ctx context.Context, siteID int64) (map[int64]models.BundleInfo, error) { + res, err := db.q.GetSiteBundleInfo(ctx, siteID) + if err != nil { + return nil, err + } + return momap.FromSlice(res, func(bi dbq.GetSiteBundleInfoRow) (int64, models.BundleInfo) { + return bi.BundleID, models.BundleInfo{ + BundleID: bi.BundleID, + PageCount: int(bi.PageCount), + IndexPageID: bi.IndexPageID.Int64, + } + }), nil +} + func dbBundleToBundle(b dbq.Bundle) models.Bundle { return models.Bundle{ ID: b.ID, diff --git a/providers/db/page.go b/providers/db/page.go index 4b209d4..788e157 100644 --- a/providers/db/page.go +++ b/providers/db/page.go @@ -16,6 +16,12 @@ var nameProvenanceToDBNameProvenance = map[models.NameProvenance]dbq.PageNamePro } var dbNameProvenanceToNameProvenance = momap.ReverseMap(nameProvenanceToDBNameProvenance) +var pageRoleToDBPageRole = map[models.PageRole]dbq.NullPageRole{ + models.NormalPageRole: {}, + models.IndexPageRole: {PageRole: dbq.PageRoleIndex, Valid: true}, +} +var dbPageRoleToPageRole = momap.ReverseMap(pageRoleToDBPageRole) + func (db *DB) InsertPage(ctx context.Context, page *models.Page) error { id, err := db.q.InsertPage(ctx, dbq.InsertPageParams{ @@ -23,6 +29,7 @@ func (db *DB) InsertPage(ctx context.Context, page *models.Page) error { BundleID: page.BundleID, Name: page.Name, NameProvenance: nameProvenanceToDBNameProvenance[page.NameProvenance], + Role: pageRoleToDBPageRole[page.Role], Title: pgtype.Text{String: page.Title, Valid: page.Title != ""}, Body: page.Body, State: dbq.PostState(page.State), @@ -43,6 +50,7 @@ func (db *DB) UpdatePage(ctx context.Context, page *models.Page) error { SiteID: page.SiteID, BundleID: page.BundleID, Name: page.Name, + Role: pageRoleToDBPageRole[page.Role], NameProvenance: nameProvenanceToDBNameProvenance[page.NameProvenance], Title: pgtype.Text{String: page.Title, Valid: page.Title != ""}, Body: page.Body, @@ -95,6 +103,7 @@ func dbPageToPage(p dbq.Page) models.Page { SiteID: p.SiteID, BundleID: p.BundleID, Name: p.Name, + Role: dbPageRoleToPageRole[p.Role], NameProvenance: dbNameProvenanceToNameProvenance[p.NameProvenance], Title: p.Title.String, Body: p.Body, diff --git a/services/pages/services.go b/services/pages/services.go index 82df64d..c289946 100644 --- a/services/pages/services.go +++ b/services/pages/services.go @@ -85,7 +85,7 @@ func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (mo post := models.Page{ SiteID: site.ID, BundleID: rootBundle.ID, - Name: s.normalizePageName(req.Title), + Name: name, NameProvenance: nameProvenance, Title: req.Title, Body: req.Body, diff --git a/services/sitebuilder/pages.go b/services/sitebuilder/pages.go index f551d76..7acb5a6 100644 --- a/services/sitebuilder/pages.go +++ b/services/sitebuilder/pages.go @@ -27,7 +27,17 @@ func (s *Service) WritePage(site models.Site, bundle models.Bundle, page models. return fmt.Errorf("theme %s not found in themes", site.Theme) } - if err := s.writePage(site, themeMeta, bundle, page); err != nil { + bundleInfo, err := s.db.GetSiteBundleInfo(ctx, site.ID) + if err != nil { + return err + } + + if err := s.writePage(pageBuildInfo{ + site: site, + themeMeta: themeMeta, + bundle: bundle, + bundleInfo: bundleInfo, + }, page); err != nil { return err } return s.publish(ctx, site) @@ -45,8 +55,23 @@ func (s *Service) DeletePage(site models.Site, page models.Page) models.Job { if err != nil { return err } + + themeMeta, ok := s.themes.Lookup(site.Theme) + if !ok { + return fmt.Errorf("theme %s not found in themes", site.Theme) + } - postFilename := s.pageFilename(site, bundle, page) + bundleInfo, err := s.db.GetSiteBundleInfo(ctx, site.ID) + if err != nil { + return err + } + + postFilename := s.pageFilename(pageBuildInfo{ + site: site, + themeMeta: themeMeta, + bundle: bundle, + bundleInfo: bundleInfo, + }, page) if os.Remove(postFilename) != nil { return nil @@ -71,6 +96,11 @@ func (s *Service) writeAllPages(ctx context.Context, site models.Site) error { bundlesByID := momap.FromSlice(bundles, func(b models.Bundle) (int64, models.Bundle) { return b.ID, b }) + bundleInfo, err := s.db.GetSiteBundleInfo(ctx, site.ID) + if err != nil { + return err + } + var startId int64 for { pages, err := s.db.ListPublishablePages(ctx, int64(startId), site.ID) @@ -81,7 +111,12 @@ func (s *Service) writeAllPages(ctx context.Context, site models.Site) error { } for _, page := range pages { - if err := s.writePage(site, themeMeta, bundlesByID[page.BundleID], page); err != nil { + if err := s.writePage(pageBuildInfo{ + site: site, + themeMeta: themeMeta, + bundle: bundlesByID[page.BundleID], + bundleInfo: bundleInfo, + }, page); err != nil { return err } } @@ -89,17 +124,24 @@ func (s *Service) writeAllPages(ctx context.Context, site models.Site) error { } } -func (s *Service) writePage(site models.Site, themeMeta models.ThemeMeta, bundle models.Bundle, page models.Page) error { - postFilename := s.pageFilename(site, bundle, page) +func (s *Service) writePage(bi pageBuildInfo, page models.Page) error { + postFilename := s.pageFilename(bi, page) frontMatter := map[string]any{ "date": page.PublishDate.Format(time.RFC3339), } if page.Title != "" { frontMatter["title"] = page.Title - } else if themeMeta.PreferTitle { + } else if bi.themeMeta.PreferTitle { frontMatter["title"] = page.PublishDate.Format(time.ANSIC) } return s.writeMarkdownFile(postFilename, frontMatter, page.Body) } + +type pageBuildInfo struct { + site models.Site + themeMeta models.ThemeMeta + bundle models.Bundle + bundleInfo map[int64]models.BundleInfo +} diff --git a/services/sitebuilder/posts.go b/services/sitebuilder/posts.go index 9789092..52a5d17 100644 --- a/services/sitebuilder/posts.go +++ b/services/sitebuilder/posts.go @@ -151,12 +151,32 @@ func (s *Service) postFilename(site models.Site, themeMeta models.ThemeMeta, pos return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.BlogPostBundle, post.CreatedAt.Format("2006-01-02-150405.md")) } -func (s *Service) pageFilename(site models.Site, bundle models.Bundle, page models.Page) string { - bundleDir := "" - if bundle.Name != models.RootBundleName { - bundleDir = bundle.Name +func (s *Service) pageFilename(bi pageBuildInfo, page models.Page) string { + isIndex := false + isLeafBundle := true + + thisBundleInfo := bi.bundleInfo[bi.bundle.ID] + if thisBundleInfo.PageCount > 1 { + isLeafBundle = false + isIndex = thisBundleInfo.IndexPageID == page.ID + } else { + isIndex = true } - pageName := page.Name + ".md" - return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), bundleDir, pageName) + bundleDir := "" + if bi.bundle.Name != models.RootBundleName { + bundleDir = bi.bundle.Name + } + + pageName := page.Name + if isIndex { + if isLeafBundle { + pageName = "index" + } else { + pageName = "_index" + } + } + + pageName += ".md" + return filepath.Join(s.hugo.SiteStagingDir(bi.site, hugo.ContentSiteDir), bundleDir, pageName) } diff --git a/services/sites/create.go b/services/sites/create.go index b8a679d..4c3cb31 100644 --- a/services/sites/create.go +++ b/services/sites/create.go @@ -43,6 +43,7 @@ func (s *Service) CreateSite(ctx context.Context, user models.User, name string) Title: "Welcome to the home page", Body: "This is the home page", State: models.PostStatePublished, + Role: models.IndexPageRole, PublishDate: time.Now(), CreatedAt: time.Now(), UpdatedAt: time.Now(), diff --git a/sql/queries/bundles.sql b/sql/queries/bundles.sql index 6d2a497..d11c9bd 100644 --- a/sql/queries/bundles.sql +++ b/sql/queries/bundles.sql @@ -10,4 +10,12 @@ INSERT INTO bundles ( SELECT * FROM bundles WHERE site_id = $1; -- name: GetBundleWithID :one -SELECT * FROM bundles WHERE id = $1; \ No newline at end of file +SELECT * FROM bundles WHERE id = $1; + +-- name: GetSiteBundleInfo :many +WITH page_counts AS ( + SELECT b.bundle_id, count(*) AS page_count FROM pages b WHERE b.site_id = $1 GROUP BY bundle_id +), index_pages AS ( + SELECT p.id AS index_page_id, p.bundle_id FROM pages p WHERE p.site_id = $1 AND p.role = 'index' +) +SELECT b.bundle_id, b.page_count, p.index_page_id FROM page_counts b LEFT OUTER JOIN index_pages p ON b.bundle_id = p.bundle_id; diff --git a/sql/queries/pages.sql b/sql/queries/pages.sql index d3b77db..db5fe71 100644 --- a/sql/queries/pages.sql +++ b/sql/queries/pages.sql @@ -5,14 +5,15 @@ INSERT INTO pages ( name, name_provenance, title, - role, + post_type_id, body, state, props, + role, publish_date, created_at, updated_at -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12) RETURNING id; -- name: UpdatePage :exec @@ -22,13 +23,14 @@ UPDATE pages SET name = $4, name_provenance = $5, title = $6, - role = $7, - body = $8, - state = $9, - props = $10, - publish_date = $11, - created_at = $12, - updated_at = $13 + post_type_id = $7, + role = $8, + body = $9, + state = $10, + props = $11, + publish_date = $12, + created_at = $13, + updated_at = $14 WHERE id = $1; -- name: ListPublishablePages :many diff --git a/sql/schema/1_init.up.sql b/sql/schema/1_init.up.sql index 6524422..772df25 100644 --- a/sql/schema/1_init.up.sql +++ b/sql/schema/1_init.up.sql @@ -17,6 +17,10 @@ CREATE TYPE page_name_provenance AS ENUM ( 'date' ); +CREATE TYPE page_role AS ENUM ( + 'index' +); + CREATE TABLE users ( id BIGSERIAL NOT NULL PRIMARY KEY, email TEXT NOT NULL UNIQUE, @@ -36,7 +40,7 @@ CREATE TABLE sites ( -- Post role is used to describe a specific kind of post, such as a link. -- When set, it specifies the layout to use for the page -CREATE TABLE post_roles ( +CREATE TABLE post_types ( id BIGSERIAL NOT NULL PRIMARY KEY, site_id BIGINT NOT NULL, layout_name TEXT NOT NULL, @@ -48,7 +52,7 @@ CREATE TABLE posts ( id BIGSERIAL NOT NULL PRIMARY KEY, site_id BIGINT NOT NULL, title TEXT, - role BIGINT, + post_type_id BIGINT, body TEXT NOT NULL, state post_state NOT NULL, props JSON NOT NULL, @@ -56,7 +60,7 @@ CREATE TABLE posts ( created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, - FOREIGN KEY (role) REFERENCES post_roles (id) ON DELETE CASCADE, + FOREIGN KEY (post_type_id) REFERENCES post_types (id) ON DELETE CASCADE, FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE ); @@ -77,19 +81,21 @@ CREATE TABLE pages ( name TEXT NOT NULL, name_provenance page_name_provenance NOT NULL, title TEXT, - role BIGINT, + post_type_id BIGINT, body TEXT NOT NULL, state post_state NOT NULL, props JSON NOT NULL, + role page_role, publish_date TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE (bundle_id, name), FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE, - FOREIGN KEY (role) REFERENCES post_roles (id) ON DELETE CASCADE, + FOREIGN KEY (post_type_id) REFERENCES post_types (id) ON DELETE CASCADE, FOREIGN KEY (bundle_id) REFERENCES sites (id) ON DELETE CASCADE ); +CREATE UNIQUE INDEX page_bundle_id_role ON pages (bundle_id, role) WHERE (role is NOT null); CREATE TABLE publish_targets ( id BIGSERIAL NOT NULL PRIMARY KEY, From 3cf4294e87430d69ed3beab2d600b13f5145f642 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 16 Feb 2025 16:12:50 +1100 Subject: [PATCH 3/5] Fixed handling of content only vs structural page changes. --- services/pages/services.go | 26 ++++++++++++++++++++++-- services/sitebuilder/service.go | 36 +++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/services/pages/services.go b/services/pages/services.go index c289946..8d43616 100644 --- a/services/pages/services.go +++ b/services/pages/services.go @@ -48,13 +48,15 @@ func (s *Service) DeletePage(ctx context.Context, site models.Site, id int) erro post, err := s.db.GetPage(ctx, int64(id)) if err != nil { return err + } else if post.SiteID != site.ID { + return errors.New("page not found") } if err := s.db.DeletePage(ctx, int64(id)); err != nil { return err } - return s.jobs.Queue(ctx, s.sb.DeletePage(site, post)) + return s.jobs.Queue(ctx, s.sb.RebuildSiteContent(site, site)) } func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (models.Page, error) { @@ -97,6 +99,10 @@ func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (mo return models.Page{}, err } + if err := s.jobs.Queue(ctx, s.sb.RebuildSiteContent(site, site)); err != nil { + return models.Page{}, err + } + return post, nil } @@ -117,6 +123,8 @@ func (s *Service) Update(ctx context.Context, site models.Site, pageID int64, re return models.Page{}, errors.New("page not found") } + oldPage := page + // Update the title if it wasn't set by the user if page.NameProvenance != models.UserNameProvenance { if req.Title == "" { @@ -134,6 +142,20 @@ func (s *Service) Update(ctx context.Context, site models.Site, pageID int64, re return models.Page{}, err } + // A content only change involves rewriting the file content. Anything else is a structural change + // that will need rewriting of all the page content. + contentOnlyChange := page.Name == oldPage.Name && page.Role == oldPage.Role + + if contentOnlyChange { + if err := s.jobs.Queue(ctx, s.sb.WritePage(site, bundle, page)); err != nil { + return models.Page{}, err + } + } else { + if err := s.jobs.Queue(ctx, s.sb.RebuildSiteContent(site, site)); err != nil { + return models.Page{}, err + } + } + return page, nil } @@ -153,7 +175,7 @@ func (s *Service) save(ctx context.Context, site models.Site, bundle models.Bund } } - return s.jobs.Queue(ctx, s.sb.WritePage(site, bundle, *page)) + return nil } func (s *Service) normalizePageName(title string) string { diff --git a/services/sitebuilder/service.go b/services/sitebuilder/service.go index b7c7faa..dabe65d 100644 --- a/services/sitebuilder/service.go +++ b/services/sitebuilder/service.go @@ -64,6 +64,17 @@ func (s *Service) RebuildSite(oldSite, newSite models.Site) models.Job { } } +func (s *Service) RebuildSiteContent(oldSite, newSite models.Site) models.Job { + return models.Job{ + Do: func(ctx context.Context) error { + s.signalSiteBuildingStarted(ctx, newSite) + defer s.signalSiteBuildingFinished(ctx, newSite) + + return s.rebuildContent(ctx, oldSite, newSite) + }, + } +} + func (s *Service) rebuildSite(ctx context.Context, oldSite, newSite models.Site) error { // Teardown the existing site siteDir := s.hugo.SiteStagingDir(oldSite, hugo.BaseSiteDir) @@ -75,6 +86,28 @@ func (s *Service) rebuildSite(ctx context.Context, oldSite, newSite models.Site) return err } + if err := s.writeAllContent(ctx, newSite); err != nil { + return err + } + + return s.publish(ctx, newSite) +} + +func (s *Service) rebuildContent(ctx context.Context, oldSite, newSite models.Site) error { + // Teardown the existing site + siteDir := s.hugo.SiteStagingDir(oldSite, hugo.ContentSiteDir) + if err := os.RemoveAll(siteDir); err != nil { + return err + } + + if err := s.writeAllContent(ctx, newSite); err != nil { + return err + } + + return s.publish(ctx, newSite) +} + +func (s *Service) writeAllContent(ctx context.Context, newSite models.Site) error { if err := s.writeAllPosts(ctx, newSite); err != nil { return err } @@ -83,7 +116,7 @@ func (s *Service) rebuildSite(ctx context.Context, oldSite, newSite models.Site) return err } - return s.publish(ctx, newSite) + return nil } func (s *Service) fullRebuildNecessary(ctx context.Context, site models.Site) (bool, error) { @@ -152,4 +185,3 @@ func (s *Service) signalSiteBuildingStarted(ctx context.Context, site models.Sit func (s *Service) signalSiteBuildingFinished(ctx context.Context, site models.Site) { s.bus.Fire(models.Event{Type: models.EventSiteBuildingDone, Data: site}) } - From 68aa9c0e1398b8e10d1bf8e07a5549461ccf54a4 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 17 Feb 2025 21:41:36 +1100 Subject: [PATCH 4/5] Added site previewing This will generate a local version of the Hugo site and serve it via the server --- config/config.go | 5 ++++ main.go | 6 +++- models/theme.go | 3 ++ providers/hugo/config.go | 1 + providers/hugo/provider.go | 50 +++++++++++++++++++++------------ services/sitebuilder/pages.go | 5 ++-- services/sitebuilder/posts.go | 2 +- services/sitebuilder/publish.go | 15 ++++------ services/sitebuilder/service.go | 7 ++++- 9 files changed, 61 insertions(+), 33 deletions(-) diff --git a/config/config.go b/config/config.go index e7bd671..518e082 100644 --- a/config/config.go +++ b/config/config.go @@ -13,6 +13,7 @@ type Config struct { DataStagingDir string `env:"DATA_STAGING_DIR,default=staging"` DataScratchDir string `env:"DATA_SCRATCH_DIR,default=scratch"` + DataPreviewDir string `env:"DATA_PREVIEW_DIR,default=preview"` } func Load() (cfg Config, err error) { @@ -27,6 +28,10 @@ func (c Config) StagingDir() string { return filepath.Join(c.DataDir, c.DataStagingDir) } +func (c Config) PreviewDir() string { + return filepath.Join(c.DataDir, c.DataPreviewDir) +} + func (c Config) ScratchDir() string { return filepath.Join(c.DataDir, c.DataScratchDir) } diff --git a/main.go b/main.go index 5ff1f4b..fea03d0 100644 --- a/main.go +++ b/main.go @@ -72,7 +72,7 @@ func main() { return } - hugoProvider, err := hugo.New(cfg.StagingDir(), cfg.ScratchDir()) + hugoProvider, err := hugo.New(cfg.StagingDir(), cfg.PreviewDir(), cfg.ScratchDir()) if err != nil { log.Fatal(err) } @@ -129,6 +129,10 @@ func main() { app.Post("/sites", siteHandlers.Create) app.Get("/sites/:siteId", siteHandlers.Show) + app.Use("/preview", static.New(cfg.PreviewDir(), static.Config{ + Browse: true, + })) + sr := app.Group("/sites/:siteId") sr.Use(siteHandlers.WithSite()) sr.Post("/rebuild", siteHandlers.Rebuild) diff --git a/models/theme.go b/models/theme.go index e832748..69e9d81 100644 --- a/models/theme.go +++ b/models/theme.go @@ -8,6 +8,9 @@ type ThemeMeta struct { // Indicates that this theme prefers posts have titles. PreferTitle bool + // Indicates that the theme doesn't automatically put titles on pages + AddTitleToPages bool + // Page bundle for "blog" posts BlogPostBundle string `json:"post_dir"` } diff --git a/providers/hugo/config.go b/providers/hugo/config.go index e888090..e28a736 100644 --- a/providers/hugo/config.go +++ b/providers/hugo/config.go @@ -5,6 +5,7 @@ type hugoConfig struct { LanguageCode string `yaml:"languageCode"` Title string `yaml:"title"` Theme string `yaml:"theme"` + CanonifyURLs bool `yaml:"canonifyURLs,omitempty"` Markup hugoConfigMarkup `yaml:"markup"` } diff --git a/providers/hugo/provider.go b/providers/hugo/provider.go index 7fc516a..0cf94be 100644 --- a/providers/hugo/provider.go +++ b/providers/hugo/provider.go @@ -2,6 +2,7 @@ package hugo import ( "context" + "fmt" "gopkg.in/yaml.v3" "lmika.dev/lmika/hugo-cms/models" "log" @@ -12,12 +13,14 @@ import ( type Provider struct { stagingDir string + previewDir string scratchDir string } -func New(stagingDir, scratchDir string) (*Provider, error) { +func New(stagingDir, previewDir, scratchDir string) (*Provider, error) { return &Provider{ stagingDir: stagingDir, + previewDir: previewDir, scratchDir: scratchDir, }, nil } @@ -47,42 +50,53 @@ 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 +func (p *Provider) PreviewSite(ctx context.Context, site models.Site) (outDir string, err error) { + previewTarget := models.PublishTarget{ + URL: fmt.Sprintf("http://localhost:3000/preview/%s", site.Name), } - outDir, err = os.MkdirTemp(p.scratchDir, site.Name+"-*") + return p.publishSiteAt(ctx, p.previewDir, site, previewTarget, "hugoPreview.yaml") +} + +func (p *Provider) PublishSite(ctx context.Context, site models.Site, target models.PublishTarget) (outDir string, err error) { + return p.publishSiteAt(ctx, p.scratchDir, site, target, "hugo.yaml") +} + +func (p *Provider) publishSiteAt(ctx context.Context, dir string, site models.Site, target models.PublishTarget, configFile string) (outDir string, err error) { + baseSiteDir, err := filepath.Abs(p.SiteStagingDir(site, BaseSiteDir)) if err != nil { - return "", nil, err - } - clean = func() { - os.RemoveAll(outDir) + return "", err } - outDir, err = filepath.Abs(outDir) + outDir, err = filepath.Abs(filepath.Join(dir, site.Name)) if err != nil { - return "", nil, err + return "", err + } + + if err := os.MkdirAll(outDir, 0755); err != nil { + return "", err } cmd := exec.CommandContext(ctx, "hugo", - "--source", p.SiteStagingDir(site, BaseSiteDir), + "--source", baseSiteDir, "--destination", outDir, + "--config", filepath.Join(baseSiteDir, configFile), "--baseURL", target.URL) cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout if err := cmd.Run(); err != nil { - return "", clean, err + return "", err } - return outDir, clean, nil + return outDir, nil } -func (p *Provider) ReconfigureSite(ctx context.Context, site models.Site) error { +func (p *Provider) ReconfigureSite(ctx context.Context, configBase string, site models.Site) error { hugoCfg := hugoConfig{ Title: site.Title, LanguageCode: "en", Theme: site.Theme, + CanonifyURLs: configBase == "hugoPreview", Markup: hugoConfigMarkup{ Goldmark: hugoGoldmarkConfig{ Renderer: hugoGoldmarkRendererConfig{ @@ -97,12 +111,12 @@ func (p *Provider) ReconfigureSite(ctx context.Context, site models.Site) error return err } - if err := os.WriteFile(filepath.Join(p.SiteStagingDir(site, BaseSiteDir), "hugo.yaml"), ymlBytes, 0644); err != nil { + if err := os.WriteFile(filepath.Join(p.SiteStagingDir(site, BaseSiteDir), configBase+".yaml"), ymlBytes, 0644); err != nil { return err } - if _, err := os.Stat(filepath.Join(p.SiteStagingDir(site, BaseSiteDir), "hugo.toml")); err == nil { - if err := os.Remove(filepath.Join(p.SiteStagingDir(site, BaseSiteDir), "hugo.toml")); err != nil { + if _, err := os.Stat(filepath.Join(p.SiteStagingDir(site, BaseSiteDir), configBase+".toml")); err == nil { + if err := os.Remove(filepath.Join(p.SiteStagingDir(site, BaseSiteDir), configBase+".toml")); err != nil { return err } } diff --git a/services/sitebuilder/pages.go b/services/sitebuilder/pages.go index 7acb5a6..c253c59 100644 --- a/services/sitebuilder/pages.go +++ b/services/sitebuilder/pages.go @@ -55,7 +55,7 @@ func (s *Service) DeletePage(site models.Site, page models.Page) models.Job { if err != nil { return err } - + themeMeta, ok := s.themes.Lookup(site.Theme) if !ok { return fmt.Errorf("theme %s not found in themes", site.Theme) @@ -130,10 +130,9 @@ func (s *Service) writePage(bi pageBuildInfo, page models.Page) error { frontMatter := map[string]any{ "date": page.PublishDate.Format(time.RFC3339), } + if page.Title != "" { frontMatter["title"] = page.Title - } else if bi.themeMeta.PreferTitle { - frontMatter["title"] = page.PublishDate.Format(time.ANSIC) } return s.writeMarkdownFile(postFilename, frontMatter, page.Body) diff --git a/services/sitebuilder/posts.go b/services/sitebuilder/posts.go index 52a5d17..e67a5e2 100644 --- a/services/sitebuilder/posts.go +++ b/services/sitebuilder/posts.go @@ -156,7 +156,7 @@ func (s *Service) pageFilename(bi pageBuildInfo, page models.Page) string { isLeafBundle := true thisBundleInfo := bi.bundleInfo[bi.bundle.ID] - if thisBundleInfo.PageCount > 1 { + if thisBundleInfo.PageCount > 1 || bi.bundle.Name == models.RootBundleName { isLeafBundle = false isIndex = thisBundleInfo.IndexPageID == page.ID } else { diff --git a/services/sitebuilder/publish.go b/services/sitebuilder/publish.go index 509d86e..e730087 100644 --- a/services/sitebuilder/publish.go +++ b/services/sitebuilder/publish.go @@ -10,13 +10,17 @@ func (s *Service) Publish(site models.Site) models.Job { Do: func(ctx context.Context) error { s.signalSiteBuildingStarted(ctx, site) defer s.signalSiteBuildingFinished(ctx, site) - + return s.publish(ctx, site) }, } } func (s *Service) publish(ctx context.Context, site models.Site) error { + if _, err := s.hugo.PreviewSite(ctx, site); err != nil { + return err + } + targets, err := s.db.GetPublishTargets(ctx, site.ID) if err != nil { return err @@ -31,14 +35,7 @@ func (s *Service) publish(ctx context.Context, site models.Site) error { } 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 - + outDir, err := s.hugo.PublishSite(ctx, site, target) if err != nil { return err } diff --git a/services/sitebuilder/service.go b/services/sitebuilder/service.go index dabe65d..929af90 100644 --- a/services/sitebuilder/service.go +++ b/services/sitebuilder/service.go @@ -172,9 +172,14 @@ func (s *Service) createSite(ctx context.Context, site models.Site) error { return err } - if err := s.hugo.ReconfigureSite(ctx, site); err != nil { + if err := s.hugo.ReconfigureSite(ctx, "hugo", site); err != nil { return err } + + if err := s.hugo.ReconfigureSite(ctx, "hugoPreview", site); err != nil { + return err + } + return nil } From 295811411ec240344dd2567e2296d17ee393163b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 18 Feb 2025 21:26:24 +1100 Subject: [PATCH 5/5] pages: some fixups --- config/config.go | 1 + main.go | 2 +- providers/hugo/provider.go | 37 ++++++++++++++++++++++---------- services/sitebuilder/service.go | 4 ++-- services/sites/service.go | 38 +++++++++++++++++---------------- sql/schema/1_init.up.sql | 20 +++++++++++------ templates/layouts/site.html | 1 + 7 files changed, 64 insertions(+), 39 deletions(-) diff --git a/config/config.go b/config/config.go index 518e082..643334e 100644 --- a/config/config.go +++ b/config/config.go @@ -10,6 +10,7 @@ type Config struct { NetlifyAuthToken string `env:"NETLIFY_AUTH_TOKEN"` DataDir string `env:"DATA_DIR"` EncryptedCookieKey string `env:"ENCRYPTED_COOKIE_KEY"` + BaseURL string `env:"BASE_URL,default=http://localhost:3000/"` DataStagingDir string `env:"DATA_STAGING_DIR,default=staging"` DataScratchDir string `env:"DATA_SCRATCH_DIR,default=scratch"` diff --git a/main.go b/main.go index fea03d0..6d4da92 100644 --- a/main.go +++ b/main.go @@ -72,7 +72,7 @@ func main() { return } - hugoProvider, err := hugo.New(cfg.StagingDir(), cfg.PreviewDir(), cfg.ScratchDir()) + hugoProvider, err := hugo.New(cfg.StagingDir(), cfg.BaseURL, cfg.PreviewDir(), cfg.ScratchDir()) if err != nil { log.Fatal(err) } diff --git a/providers/hugo/provider.go b/providers/hugo/provider.go index 0cf94be..2860c3a 100644 --- a/providers/hugo/provider.go +++ b/providers/hugo/provider.go @@ -2,26 +2,33 @@ package hugo import ( "context" - "fmt" "gopkg.in/yaml.v3" "lmika.dev/lmika/hugo-cms/models" "log" + "net/url" "os" "os/exec" "path/filepath" ) type Provider struct { - stagingDir string - previewDir string - scratchDir string + stagingDir string + previewDir string + previewBaseURL *url.URL + scratchDir string } -func New(stagingDir, previewDir, scratchDir string) (*Provider, error) { +func New(stagingDir, previewBaseURL, previewDir, scratchDir string) (*Provider, error) { + baseURL, err := url.Parse(previewBaseURL) + if err != nil { + return nil, err + } + return &Provider{ - stagingDir: stagingDir, - previewDir: previewDir, - scratchDir: scratchDir, + stagingDir: stagingDir, + previewBaseURL: baseURL, + previewDir: previewDir, + scratchDir: scratchDir, }, nil } @@ -51,8 +58,12 @@ func (p *Provider) NewSite(ctx context.Context, site models.Site) error { } func (p *Provider) PreviewSite(ctx context.Context, site models.Site) (outDir string, err error) { + previewURL, err := p.previewBaseURL.Parse("preview/" + site.Name) + if err != nil { + return "", err + } previewTarget := models.PublishTarget{ - URL: fmt.Sprintf("http://localhost:3000/preview/%s", site.Name), + URL: previewURL.String(), } return p.publishSiteAt(ctx, p.previewDir, site, previewTarget, "hugoPreview.yaml") @@ -80,6 +91,7 @@ func (p *Provider) publishSiteAt(ctx context.Context, dir string, site models.Si cmd := exec.CommandContext(ctx, "hugo", "--source", baseSiteDir, "--destination", outDir, + "--quiet", "--config", filepath.Join(baseSiteDir, configFile), "--baseURL", target.URL) cmd.Stderr = os.Stderr @@ -91,12 +103,11 @@ func (p *Provider) publishSiteAt(ctx context.Context, dir string, site models.Si return outDir, nil } -func (p *Provider) ReconfigureSite(ctx context.Context, configBase string, site models.Site) error { +func (p *Provider) ReconfigureSite(ctx context.Context, isPreviewConfig bool, configBase string, site models.Site) error { hugoCfg := hugoConfig{ Title: site.Title, LanguageCode: "en", Theme: site.Theme, - CanonifyURLs: configBase == "hugoPreview", Markup: hugoConfigMarkup{ Goldmark: hugoGoldmarkConfig{ Renderer: hugoGoldmarkRendererConfig{ @@ -106,6 +117,10 @@ func (p *Provider) ReconfigureSite(ctx context.Context, configBase string, site }, } + if isPreviewConfig { + hugoCfg.CanonifyURLs = true + } + ymlBytes, err := yaml.Marshal(hugoCfg) if err != nil { return err diff --git a/services/sitebuilder/service.go b/services/sitebuilder/service.go index 929af90..7ba2280 100644 --- a/services/sitebuilder/service.go +++ b/services/sitebuilder/service.go @@ -172,11 +172,11 @@ func (s *Service) createSite(ctx context.Context, site models.Site) error { return err } - if err := s.hugo.ReconfigureSite(ctx, "hugo", site); err != nil { + if err := s.hugo.ReconfigureSite(ctx, false, "hugo", site); err != nil { return err } - if err := s.hugo.ReconfigureSite(ctx, "hugoPreview", site); err != nil { + if err := s.hugo.ReconfigureSite(ctx, true, "hugoPreview", site); err != nil { return err } diff --git a/services/sites/service.go b/services/sites/service.go index 9380ff1..8ad2bd7 100644 --- a/services/sites/service.go +++ b/services/sites/service.go @@ -61,26 +61,28 @@ func (s *Service) SaveSettings(ctx context.Context, site models.Site, newSetting return err } - pubTarget, err := s.db.GetPublishTargetBySiteRole(ctx, newSite.ID, models.TargetRoleProduction) - if err == nil { - pubTarget.TargetRef = newSettings.TargetRef - pubTarget.URL = newSettings.TargetURL - if err := s.db.UpdatePublishTarget(ctx, pubTarget); err != nil { + if newSettings.TargetRef != "" && newSettings.TargetURL != "" { + pubTarget, err := s.db.GetPublishTargetBySiteRole(ctx, newSite.ID, models.TargetRoleProduction) + if err == nil { + pubTarget.TargetRef = newSettings.TargetRef + pubTarget.URL = newSettings.TargetURL + if err := s.db.UpdatePublishTarget(ctx, pubTarget); err != nil { + return err + } + } else if errors.Is(err, pgx.ErrNoRows) { + pubTarget = models.PublishTarget{ + SiteID: newSite.ID, + Role: models.TargetRoleProduction, + Type: models.TargetTypeNetlify, + URL: newSettings.TargetURL, + TargetRef: newSettings.TargetRef, + } + if err := s.db.InsertPublishTarget(ctx, &pubTarget); err != nil { + return err + } + } else { return err } - } else if errors.Is(err, pgx.ErrNoRows) { - pubTarget = models.PublishTarget{ - SiteID: newSite.ID, - Role: models.TargetRoleProduction, - Type: models.TargetTypeNetlify, - URL: newSettings.TargetURL, - TargetRef: newSettings.TargetRef, - } - if err := s.db.InsertPublishTarget(ctx, &pubTarget); err != nil { - return err - } - } else { - return err } return s.jobs.Queue(ctx, s.sb.RebuildSite(site, newSite)) diff --git a/sql/schema/1_init.up.sql b/sql/schema/1_init.up.sql index 772df25..ab6f576 100644 --- a/sql/schema/1_init.up.sql +++ b/sql/schema/1_init.up.sql @@ -3,6 +3,10 @@ CREATE TYPE post_state AS ENUM ( 'published' ); +CREATE TYPE post_format AS ENUM ( + 'markdown' +); + CREATE TYPE target_role AS ENUM ( 'production' ); @@ -49,16 +53,17 @@ CREATE TABLE post_types ( ); CREATE TABLE posts ( - id BIGSERIAL NOT NULL PRIMARY KEY, - site_id BIGINT NOT NULL, + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, title TEXT, post_type_id BIGINT, - body TEXT NOT NULL, - state post_state NOT NULL, - props JSON NOT NULL, + format post_format NOT NULL DEFAULT 'markdown', + body TEXT NOT NULL, + state post_state NOT NULL, + props JSON NOT NULL, publish_date TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, FOREIGN KEY (post_type_id) REFERENCES post_types (id) ON DELETE CASCADE, FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE @@ -80,6 +85,7 @@ CREATE TABLE pages ( bundle_id BIGINT NOT NULL, name TEXT NOT NULL, name_provenance page_name_provenance NOT NULL, + format post_format NOT NULL DEFAULT 'markdown', title TEXT, post_type_id BIGINT, body TEXT NOT NULL, diff --git a/templates/layouts/site.html b/templates/layouts/site.html index 45bd5dc..81536c4 100644 --- a/templates/layouts/site.html +++ b/templates/layouts/site.html @@ -14,6 +14,7 @@

Hugo CMS