Compare commits

...

6 commits

Author SHA1 Message Date
Leon Mika c5925e16e0 Merge remote-tracking branch 'origin/main' 2025-02-21 21:16:44 +11:00
Leon Mika 295811411e pages: some fixups 2025-02-18 21:26:24 +11:00
Leon Mika 68aa9c0e13 Added site previewing
This will generate a local version of the Hugo site and serve it via the server
2025-02-17 21:41:36 +11:00
Leon Mika 3cf4294e87 Fixed handling of content only vs structural page changes. 2025-02-16 16:12:50 +11:00
Leon Mika 573517565d Made some changes to how index pages are made 2025-02-16 14:06:45 +11:00
Leon Mika ba12398d2f Started working on pages 2025-02-16 11:43:22 +11:00
35 changed files with 1753 additions and 197 deletions

View file

@ -10,9 +10,11 @@ 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"`
DataPreviewDir string `env:"DATA_PREVIEW_DIR,default=preview"`
}
func Load() (cfg Config, err error) {
@ -27,6 +29,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)
}

116
gen/sqlc/dbq/bundles.sql.go Normal file
View file

@ -0,0 +1,116 @@
// 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 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,
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
}

View file

@ -11,6 +11,90 @@ 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 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 (
@ -135,15 +219,48 @@ 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
PostTypeID pgtype.Int8
Body string
State PostState
Props []byte
Role NullPageRole
PublishDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
UpdatedAt pgtype.Timestamp
}
type Post struct {
ID int64
SiteID int64
Title pgtype.Text
PostTypeID pgtype.Int8
Body string
State PostState
Props []byte
PublishDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
UpdatedAt pgtype.Timestamp
}
type PostType struct {
ID int64
SiteID int64
LayoutName string
}
type PublishTarget struct {

242
gen/sqlc/dbq/pages.sql.go Normal file
View file

@ -0,0 +1,242 @@
// 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, 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) {
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.PostTypeID,
&i.Body,
&i.State,
&i.Props,
&i.Role,
&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,
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, $12, $12)
RETURNING id
`
type InsertPageParams struct {
SiteID int64
BundleID int64
Name string
NameProvenance PageNameProvenance
Title pgtype.Text
PostTypeID pgtype.Int8
Body string
State PostState
Props []byte
Role NullPageRole
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.PostTypeID,
arg.Body,
arg.State,
arg.Props,
arg.Role,
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, 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) {
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.PostTypeID,
&i.Body,
&i.State,
&i.Props,
&i.Role,
&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, 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
`
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.PostTypeID,
&i.Body,
&i.State,
&i.Props,
&i.Role,
&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,
post_type_id = $7,
role = $8,
body = $9,
state = $10,
props = $11,
publish_date = $12,
created_at = $13,
updated_at = $14
WHERE id = $1
`
type UpdatePageParams struct {
ID int64
SiteID int64
BundleID int64
Name string
NameProvenance PageNameProvenance
Title pgtype.Text
PostTypeID pgtype.Int8
Role NullPageRole
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.PostTypeID,
arg.Role,
arg.Body,
arg.State,
arg.Props,
arg.PublishDate,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}

View file

@ -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, 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,11 +31,13 @@ func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) {
&i.ID,
&i.SiteID,
&i.Title,
&i.PostTypeID,
&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, 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) {
@ -95,11 +100,13 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
&i.ID,
&i.SiteID,
&i.Title,
&i.PostTypeID,
&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, 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 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.PostTypeID,
&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
}

2
go.mod
View file

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

2
go.sum
View file

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

123
handlers/page.go Normal file
View file

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

16
main.go
View file

@ -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"
@ -71,7 +72,7 @@ func main() {
return
}
hugoProvider, err := hugo.New(cfg.StagingDir(), cfg.ScratchDir())
hugoProvider, err := hugo.New(cfg.StagingDir(), cfg.BaseURL, cfg.PreviewDir(), cfg.ScratchDir())
if err != nil {
log.Fatal(err)
}
@ -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")
@ -126,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)
@ -137,6 +144,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)

54
models/page.go Normal file
View file

@ -0,0 +1,54 @@
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 PageRole int
const (
NormalPageRole PageRole = iota
IndexPageRole
)
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 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
}

View file

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

View file

@ -8,6 +8,9 @@ type ThemeMeta struct {
// Indicates that this theme prefers posts have titles.
PreferTitle bool
// Content directory for "blog" posts
PostDir string `json:"post_dir"`
// Indicates that the theme doesn't automatically put titles on pages
AddTitleToPages bool
// Page bundle for "blog" posts
BlogPostBundle string `json:"post_dir"`
}

65
providers/db/bundles.go Normal file
View file

@ -0,0 +1,65 @@
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"
)
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 (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,
SiteID: b.SiteID,
Name: b.Name,
CreatedAt: b.CreatedAt.Time,
UpdatedAt: b.UpdatedAt.Time,
}
}

114
providers/db/page.go Normal file
View file

@ -0,0 +1,114 @@
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)
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{
SiteID: page.SiteID,
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),
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,
Role: pageRoleToDBPageRole[page.Role],
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,
Role: dbPageRoleToPageRole[p.Role],
NameProvenance: dbNameProvenanceToNameProvenance[p.NameProvenance],
Title: p.Title.String,
Body: p.Body,
State: models.PostState(p.State),
PublishDate: p.PublishDate.Time,
CreatedAt: p.CreatedAt.Time,
}
}

View file

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

View file

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

View file

@ -5,20 +5,30 @@ import (
"gopkg.in/yaml.v3"
"lmika.dev/lmika/hugo-cms/models"
"log"
"net/url"
"os"
"os/exec"
"path/filepath"
)
type Provider struct {
stagingDir string
scratchDir string
stagingDir string
previewDir string
previewBaseURL *url.URL
scratchDir string
}
func New(stagingDir, 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,
scratchDir: scratchDir,
stagingDir: stagingDir,
previewBaseURL: baseURL,
previewDir: previewDir,
scratchDir: scratchDir,
}, nil
}
@ -47,38 +57,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) {
previewURL, err := p.previewBaseURL.Parse("preview/" + site.Name)
if err != nil {
return "", err
}
previewTarget := models.PublishTarget{
URL: previewURL.String(),
}
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,
"--quiet",
"--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, isPreviewConfig bool, configBase string, site models.Site) error {
hugoCfg := hugoConfig{
Title: site.Title,
LanguageCode: "en",
@ -92,17 +117,21 @@ func (p *Provider) ReconfigureSite(ctx context.Context, site models.Site) error
},
}
if isPreviewConfig {
hugoCfg.CanonifyURLs = true
}
ymlBytes, err := yaml.Marshal(hugoCfg)
if err != nil {
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
}
}

View file

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

205
services/pages/services.go Normal file
View file

@ -0,0 +1,205 @@
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
} 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.RebuildSiteContent(site, site))
}
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: name,
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
}
if err := s.jobs.Queue(ctx, s.sb.RebuildSiteContent(site, site)); 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")
}
oldPage := page
// 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
}
// 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
}
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 nil
}
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
}

View file

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

View file

@ -0,0 +1,146 @@
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)
}
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)
},
}
}
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
}
themeMeta, ok := s.themes.Lookup(site.Theme)
if !ok {
return fmt.Errorf("theme %s not found in themes", site.Theme)
}
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
}
// 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 })
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)
if err != nil {
return err
} else if len(pages) == 0 {
return nil
}
for _, page := range pages {
if err := s.writePage(pageBuildInfo{
site: site,
themeMeta: themeMeta,
bundle: bundlesByID[page.BundleID],
bundleInfo: bundleInfo,
}, page); err != nil {
return err
}
}
startId = pages[len(pages)-1].ID
}
}
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
}
return s.writeMarkdownFile(postFilename, frontMatter, page.Body)
}
type pageBuildInfo struct {
site models.Site
themeMeta models.ThemeMeta
bundle models.Bundle
bundleInfo map[int64]models.BundleInfo
}

View file

@ -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,35 @@ 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(bi pageBuildInfo, page models.Page) string {
isIndex := false
isLeafBundle := true
thisBundleInfo := bi.bundleInfo[bi.bundle.ID]
if thisBundleInfo.PageCount > 1 || bi.bundle.Name == models.RootBundleName {
isLeafBundle = false
isIndex = thisBundleInfo.IndexPageID == page.ID
} else {
isIndex = true
}
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)
}

View file

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

View file

@ -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,13 +86,39 @@ func (s *Service) rebuildSite(ctx context.Context, oldSite, newSite models.Site)
return err
}
if err := s.writeAllPosts(ctx, newSite); err != nil {
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
}
if err := s.writeAllPages(ctx, newSite); err != nil {
return err
}
return nil
}
func (s *Service) fullRebuildNecessary(ctx context.Context, site models.Site) (bool, error) {
dirsMustExists := []string{
s.hugo.SiteStagingDir(site, hugo.BaseSiteDir),
@ -135,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, false, "hugo", site); err != nil {
return err
}
if err := s.hugo.ReconfigureSite(ctx, true, "hugoPreview", site); err != nil {
return err
}
return nil
}

View file

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

56
services/sites/create.go Normal file
View file

@ -0,0 +1,56 @@
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,
Role: models.IndexPageRole,
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))
}

View file

@ -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 {
@ -82,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))

21
sql/queries/bundles.sql Normal file
View file

@ -0,0 +1,21 @@
-- 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;
-- 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;

49
sql/queries/pages.sql Normal file
View file

@ -0,0 +1,49 @@
-- name: InsertPage :one
INSERT INTO pages (
site_id,
bundle_id,
name,
name_provenance,
title,
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, $12, $12)
RETURNING id;
-- name: UpdatePage :exec
UPDATE pages SET
site_id = $2,
bundle_id = $3,
name = $4,
name_provenance = $5,
title = $6,
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
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;

View file

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

View file

@ -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'
);
@ -11,6 +15,16 @@ CREATE TYPE target_type AS ENUM (
'netlify'
);
CREATE TYPE page_name_provenance AS ENUM (
'user',
'title',
'date'
);
CREATE TYPE page_role AS ENUM (
'index'
);
CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
@ -28,19 +42,67 @@ 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_types (
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,
post_type_id BIGINT,
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,
FOREIGN KEY (post_type_id) REFERENCES post_types (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,
format post_format NOT NULL DEFAULT 'markdown',
title TEXT,
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 (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,
site_id BIGINT NOT NULL,

View file

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

View file

@ -14,6 +14,7 @@
<h1>Hugo CMS</h1>
<nav>
<span>{{.site.Title}}</span>
<a href="/preview/{{.site.Name}}" target="_blank">Preview</a>
{{ if .prodTarget }}
<a href="{{.prodTarget.URL}}" target="_blank">Visit</a>
{{ end }}

13
templates/pages/edit.html Normal file
View file

@ -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 -}}
<form method="post" action="{{$postTarget}}" class="post-form">
<input name="title" placeholder="Title" value="{{.page.Title}}">
<textarea name="body">{{.page.Body}}</textarea>
<div class="bottom-bar">
<input type="submit" value="Post">
</div>
</form>

View file

@ -0,0 +1,20 @@
<div>
<a href="/sites/{{.site.ID}}/pages/new">New Page</a>
</div>
{{range .pages}}
<div class="post">
{{if .Title}}
<h3>{{.Title}}</h3>
{{end}}
{{.Body | markdown}}
<div>
<a href="/sites/{{$.site.ID}}/pages/{{.ID}}">Edit</a> |
<a hx-delete="/sites/{{$.site.ID}}/pages/{{.ID}}" hx-confirm="Delete page?" hx-target="closest .post" href="#">Delete</a>
</div>
</div>
{{else}}
<p>No pages yet</p>
{{end}}