feat: journal-author content type

- journal-author (/blog/author/:author, :author the author slug else the
  login): references, Resolve, Records of the authors with a published
  post, Describe as a profile per locale, exact Match on the pattern
- golem15.journal.author_pages false resolves nothing, describes empty
  URLs and never matches
This commit is contained in:
Jakub Zych
2026-11-04 10:19:10 +01:00
parent e8e77a154c
commit 805bfdb837
7 changed files with 370 additions and 6 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
@@ -12,6 +13,7 @@ import (
"git.golem15.com/golem15/sm-journal-plugin/models"
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
@@ -39,6 +41,7 @@ const (
menuLabelAllPosts = "golem15.journal::lang.menuitem.all_journal_posts"
menuLabelCategoryPosts = "golem15.journal::lang.menuitem.category_journal_posts"
menuLabelTag = "golem15.journal::lang.menuitem.journal_tag"
menuLabelAuthor = "golem15.journal::lang.menuitem.journal_author"
)
// The default URL patterns of the menu item types. A host overrides them
@@ -51,6 +54,9 @@ const (
DefaultCategoryPattern = "/blog/category/:slug"
// DefaultTagPattern is the tag route of the vue-journal-layer.
DefaultTagPattern = "/blog/tag/:slug"
// DefaultAuthorPattern is the author route of the vue-journal-layer;
// :author is the author slug, else the login (P33-07).
DefaultAuthorPattern = "/blog/author/:author"
)
// xDefaultLocale is the single alternate's locale when translate has no
@@ -63,6 +69,7 @@ var (
postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
categoryPlaceholders = []string{":slug", ":id"}
tagPlaceholders = []string{":slug", ":id"}
authorPlaceholders = []string{":author", ":id"}
)
// MenuItemTypes returns the Journal menu item types, the typed successors
@@ -87,8 +94,13 @@ var (
// name); it resolves to the tag's URL, /blog/tag/:slug by default, or to
// nothing when the host has no tag pages (TagPagesKey false). It has no
// PHP counterpart.
// - journal-author: one author's page, picked from the backend users with
// at least one published post (References lists them by name); it
// resolves to /blog/author/:author by default, :author being the author
// slug, else the login, or to nothing when the host has no author pages
// (AuthorPagesKey false). It has no PHP counterpart (Phase 33, D-43).
//
// journal-category, journal-post and journal-tag carry content descriptors
// journal-category, journal-post, journal-tag and journal-author carry content descriptors
// (pact.ContentDescriptor, content_descriptors.go), so consumers such as
// sitemaps and metadata editors describe, page and match their records.
//
@@ -199,6 +211,23 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
Kind: pact.ContentKindCollection,
},
},
{
Key: MenuTypeAuthor,
Label: menuLabelAuthor,
DefaultPattern: DefaultAuthorPattern,
Placeholders: append([]string(nil), authorPlaceholders...),
References: m.authorReferences,
Resolve: m.resolveAuthor,
Content: &pact.ContentDescriptor{
// Authors are backend users; they have no Journal admin form.
Controller: "",
Model: cabana.BackendUserMorphType,
Records: m.listAuthors,
Describe: m.describeAuthor,
Match: m.matchAuthor,
Kind: pact.ContentKindProfile,
},
},
}
}
@@ -818,3 +847,256 @@ func phpTruthy(v any) bool {
}
return true
}
// publishedAuthors restricts a backend_users query to live users with at
// least one published post.
func publishedAuthors(db *gorm.DB) *gorm.DB {
return db.Table("backend_users").Select(authorColumns).
Where("deleted_at IS NULL").
Where("id IN (?)", publishedPosts(db.Session(&gorm.Session{NewDB: true}).Model(&models.Post{})).Select("user_id"))
}
// authorLastMods maps each author id to the newest update of their
// published posts.
func authorLastMods(ctx context.Context, db *gorm.DB, ids []uint) (map[uint]time.Time, error) {
out := map[uint]time.Time{}
if len(ids) == 0 {
return out, nil
}
var rows []struct {
UserID uint `gorm:"column:user_id"`
LastMod *time.Time `gorm:"column:last_mod"`
}
if err := publishedPosts(db.WithContext(ctx).Model(&models.Post{})).
Select("user_id, MAX(updated_at) AS last_mod").Where("user_id IN ?", ids).
Group("user_id").Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
if r.LastMod != nil {
out[r.UserID] = *r.LastMod
}
}
return out, nil
}
// authorParams are an author's placeholder values (not translatable).
func authorParams(a authorRow) func(string) [][2]string {
return func(string) [][2]string {
return [][2]string{{"author", a.slug()}, {"id", strconv.FormatUint(uint64(a.ID), 10)}}
}
}
// authorReferences lists the authors with a published post by name, ties
// by id.
func (m menuTypes) authorReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var rows []authorRow
if err := publishedAuthors(db).Find(&rows).Error; err != nil {
return nil, fmt.Errorf("golem15.journal: menu item author references: %w", err)
}
refs := make([]pact.MenuItemReference, 0, len(rows))
for _, a := range rows {
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(a.ID), 10), Label: a.name()})
}
sortReferences(refs)
return refs, nil
}
// sortReferences orders references by label, ties by id.
func sortReferences(refs []pact.MenuItemReference) {
sort.SliceStable(refs, func(i, j int) bool {
if refs[i].Label != refs[j].Label {
return refs[i].Label < refs[j].Label
}
a, _ := strconv.ParseUint(refs[i].Value, 10, 64)
b, _ := strconv.ParseUint(refs[j].Value, 10, 64)
return a < b
})
}
// findPublishedAuthor reads one author with a published post by id.
func findPublishedAuthor(db *gorm.DB, id uint) (*authorRow, error) {
var rows []authorRow
if err := publishedAuthors(db).Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, err
}
return &rows[0], nil
}
// resolveAuthor resolves one journal-author item; an unknown author, one
// without a published post, and any author on a host without author pages
// resolve to nothing.
func (m menuTypes) resolveAuthor(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
id, ok := menuReferenceID(in.Reference)
if !ok || !m.authorPages() {
return nil, nil
}
db, err := m.db(ctx)
if err != nil {
return nil, err
}
a, err := findPublishedAuthor(db, id)
if err != nil || a == nil {
return nil, err
}
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
mods, err := authorLastMods(ctx, db, []uint{a.ID})
if err != nil {
return nil, err
}
var mtime *time.Time
if t, ok := mods[a.ID]; ok {
mtime = &t
}
name := a.name()
item := resolvedMenuItem(in.Pattern, locales, name, mtime, authorParams(*a), func(string) string { return name })
return &item, nil
}
// listAuthors is the Records of journal-author: the authors with a
// published post, in id order.
func (m menuTypes) listAuthors(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
db, err := m.contentDB(ctx)
if err != nil {
return pact.ContentPage{}, err
}
limit := pageLimit(in.Limit)
var rows []authorRow
if err := publishedAuthors(db).Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&rows).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(rows) > limit
if more {
rows = rows[:limit]
}
records, err := m.describeAuthors(ctx, db, rows, true, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
// describeAuthor describes one backend user as an author page; Published
// tells whether they have a published post (the only authors with a page).
func (m menuTypes) describeAuthor(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
id, ok := menuReferenceID(in.ID)
if !ok {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
var rows []authorRow
if err := db.Table("backend_users").Select(authorColumns).Where("deleted_at IS NULL AND id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
published, err := findPublishedAuthor(db, id)
if err != nil {
return nil, err
}
records, err := m.describeAuthors(ctx, db, rows, published != nil, in.Pattern, in.UnprefixedLocale)
if err != nil {
return nil, err
}
return &records[0], nil
}
// describeAuthors describes authors: the name as every locale's Title, the
// author URL per locale (empty without author pages) and the newest update
// of their published posts as LastMod.
func (m menuTypes) describeAuthors(ctx context.Context, db *gorm.DB, rows []authorRow, published bool, pattern, unprefixed string) ([]pact.ContentRecord, error) {
locales, err := loadMenuLocales(ctx, db, unprefixed)
if err != nil {
return nil, err
}
ids := make([]uint, 0, len(rows))
for _, a := range rows {
ids = append(ids, a.ID)
}
mods, err := authorLastMods(ctx, db, ids)
if err != nil {
return nil, err
}
pages := m.authorPages()
out := make([]pact.ContentRecord, 0, len(rows))
for _, a := range rows {
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(a.ID), 10), Published: published}
if t, ok := mods[a.ID]; ok {
rec.LastMod = &t
}
name := a.name()
for _, alt := range menuAlternates(pattern, locales, authorParams(a), func(string) string { return name }) {
url := alt.URL
if !pages {
url = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: name})
}
out = append(out, rec)
}
return out, nil
}
// matchAuthor is the Match of journal-author: by :author (the author slug,
// then the login) or :id among the authors with a published post, exact
// only on the author's own URL; nothing matches on a host without author
// pages.
func (m menuTypes) matchAuthor(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
if !m.authorPages() {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
find := func(values map[string]string, _ string, _ menuLocales) (uint, error) {
var rows []authorRow
switch {
case values["author"] != "":
for _, cond := range []string{"golem15_bloghub_author_slug = ?", "login = ?"} {
if err := publishedAuthors(db).Where(cond, values["author"]).Order("id ASC").Limit(1).Find(&rows).Error; err != nil {
return 0, err
}
if len(rows) > 0 {
return rows[0].ID, nil
}
}
return 0, nil
case values["id"] != "":
id, ok := menuReferenceID(values["id"])
if !ok {
return 0, nil
}
a, err := findPublishedAuthor(db, id)
if err != nil || a == nil {
return 0, err
}
return a.ID, nil
}
return 0, nil
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
a, err := findPublishedAuthor(db, id)
if err != nil || a == nil {
return nil, err
}
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
name := a.name()
return menuAlternates(in.Pattern, locales, authorParams(*a), func(string) string { return name }), nil
}
return matchContent(ctx, db, in, find, urls)
}

View File

@@ -12,14 +12,14 @@ import (
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the six
// registered types (the five PHP ones and journal-tag), the ordered placeholder fill of PHP buildUrlFromPattern,
// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the seven
// registered types (the five PHP ones, journal-tag and journal-author), the ordered placeholder fill of PHP buildUrlFromPattern,
// the post placeholders (date parts only for a published post), the
// alternates (x-default, unprefixed locale, prefixes, first alternate as the
// URL), the translation fallback, the reference parse, the category tree and
// the refusals without a booted app.
func TestMenuItemTypesPatterns(t *testing.T) {
t.Run("the six types", func(t *testing.T) {
t.Run("the seven types", func(t *testing.T) {
types := MenuItemTypes(nil)
var got []string
for _, typ := range types {
@@ -36,6 +36,7 @@ func TestMenuItemTypesPatterns(t *testing.T) {
"all-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=false|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.all_journal_posts",
"category-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.category_journal_posts",
"journal-tag|/blog/tag/:slug||nest=false|dyn=false|refs=true|:slug,:id|golem15.journal::lang.menuitem.journal_tag",
"journal-author|/blog/author/:author||nest=false|dyn=false|refs=true|:author,:id|golem15.journal::lang.menuitem.journal_author",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("types:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))