feat: content descriptors for posts, categories and the new journal-tag type

- journal-post, journal-category and journal-tag carry pact.ContentDescriptor
  (controller, morph name, Describe, Records, Match) through signpost
- Match reads the locale prefix and the pattern with signpost.MatchPattern,
  finds the record by its slug in that locale, the default slug, another
  locale's slug or :id, and is exact only on the record's own URL (D-20c)
- journal-tag (/blog/tag/:slug, P-11) with references and Resolve; host key
  golem15.journal.tag_pages false empties tag URLs and stops tag matches
- TestContentDescriptors, TestJournalTagType; README documents both
This commit is contained in:
2026-10-26 22:46:32 +01:00
parent 4b3a8bb049
commit e17cebd761
7 changed files with 850 additions and 7 deletions

View File

@@ -9,9 +9,11 @@ import (
"unicode"
"git.golem15.com/golem15/sm-journal-plugin/models"
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
@@ -43,7 +45,9 @@ func (m menuTypes) postContent() *pact.ContentDescriptor {
return &pact.ContentDescriptor{
Controller: postsControllerID,
Model: models.Post{}.MorphName(),
Records: m.listPosts,
Describe: m.describePost,
Match: m.matchPost,
}
}
@@ -210,3 +214,548 @@ func wordCount(s string) int {
}
return n
}
// TagPagesKey is the host configuration key that says whether the site has
// tag pages (default true). With false, journal-tag items resolve to
// nothing, tag descriptions carry empty URLs and tag paths never match.
const TagPagesKey = "golem15.journal.tag_pages"
// maxContentPage caps the page size of a descriptor's Records; a limit of 0
// or below asks for the cap.
const maxContentPage = 500
func pageLimit(n int) int {
if n <= 0 || n > maxContentPage {
return maxContentPage
}
return n
}
// afterID parses a Records After cursor: "" or an invalid value is the
// start.
func afterID(after string) uint {
id, _ := menuReferenceID(after)
return id
}
// tagPages reports TagPagesKey, true when unset.
func (m menuTypes) tagPages() bool {
if m.app == nil {
return true
}
app := m.app()
if app == nil || app.Config == nil || !app.Config.Has(TagPagesKey) {
return true
}
return app.Config.Bool(TagPagesKey)
}
// listPosts is the Records of journal-post: every post, drafts included, in
// id order.
func (m menuTypes) listPosts(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 posts []models.Post
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&posts).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(posts) > limit
if more {
posts = posts[:limit]
}
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
func contentPage(records []pact.ContentRecord, more bool) pact.ContentPage {
page := pact.ContentPage{Records: records}
if page.Records == nil {
page.Records = []pact.ContentRecord{}
}
if more && len(records) > 0 {
page.Next = records[len(records)-1].ID
}
return page
}
// matchPost is the Match of journal-post. The post is found by its slug in
// the path's locale, else its default slug, else a slug of another locale,
// else by :id; drafts never match. The match is exact only when the path is
// the post's own URL in that locale, so every segment (category, date, id,
// locale prefix, slug) must fit the post.
func (m menuTypes) matchPost(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
morph := models.Post{}.MorphName()
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
return publishedPosts(q.Model(&models.Post{}))
})
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
var posts []models.Post
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil || len(posts) == 0 {
return nil, err
}
items, err := resolvePostItems(ctx, db, posts, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale})
if err != nil {
return nil, err
}
return items[0].Alternates, nil
}
return matchContent(ctx, db, in, find, urls)
}
// categoryContent is the D-93 descriptor of journal-category.
func (m menuTypes) categoryContent() *pact.ContentDescriptor {
return &pact.ContentDescriptor{
Controller: categoriesControllerID,
Model: models.Category{}.MorphName(),
Records: m.listCategories,
Describe: m.describeCategory,
Match: m.matchCategory,
}
}
func (m menuTypes) describeCategory(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 cats []models.Category
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil {
return nil, err
}
if len(cats) == 0 {
return nil, nil
}
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale)
if err != nil {
return nil, err
}
return &records[0], nil
}
func (m menuTypes) listCategories(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 cats []models.Category
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&cats).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(cats) > limit
if more {
cats = cats[:limit]
}
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
// describeCategories describes categories, always published: each locale's
// URL and title as resolveCategory builds them, the translated description
// as plain text and the word count of the category's content.
func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category, pattern, unprefixed string) ([]pact.ContentRecord, error) {
ids := make([]uint, 0, len(cats))
for _, c := range cats {
ids = append(ids, c.ID)
}
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}, ids)
if err != nil {
return nil, err
}
out := make([]pact.ContentRecord, 0, len(cats))
for _, c := range cats {
item := ci.item(c)
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(c.ID), 10), Published: true, LastMod: copyTime(c.UpdatedAt)}
var words *int
if c.Content != nil && strings.TrimSpace(*c.Content) != "" {
n := wordCount(plainText(*c.Content))
words = &n
}
host := ""
if c.Description != nil {
host = *c.Description
}
for _, alt := range item.Alternates {
locale := alt.Locale
if locale == xDefaultLocale {
locale = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{
Locale: alt.Locale,
URL: alt.URL,
Title: alt.Title,
Description: plainText(ci.tr.value(locale, c.ID, "description", host)),
Words: words,
})
}
out = append(out, rec)
}
return out, nil
}
// matchCategory is the Match of journal-category, by translated or default
// slug or :id, exact only on the category's own URL in the path's locale.
func (m menuTypes) matchCategory(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
morph := models.Category{}.MorphName()
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
return q.Model(&models.Category{})
})
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
var cats []models.Category
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil || len(cats) == 0 {
return nil, err
}
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale}, []uint{id})
if err != nil {
return nil, err
}
return ci.item(cats[0]).Alternates, nil
}
return matchContent(ctx, db, in, find, urls)
}
// tagContent is the D-93 descriptor of journal-tag.
func (m menuTypes) tagContent() *pact.ContentDescriptor {
return &pact.ContentDescriptor{
Controller: tagsControllerID,
Model: models.Tag{}.MorphName(),
Records: m.listTags,
Describe: m.describeTag,
Match: m.matchTag,
}
}
// tagReferences lists every tag by name, ties by id.
func (m menuTypes) tagReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var tags []models.Tag
if err := db.Order("name ASC, id ASC").Find(&tags).Error; err != nil {
return nil, err
}
refs := make([]pact.MenuItemReference, 0, len(tags))
for _, tag := range tags {
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(tag.ID), 10), Label: tag.Name})
}
return refs, nil
}
// resolveTag resolves one journal-tag item; an unknown tag, and any tag on a
// host without tag pages, resolve to nothing.
func (m menuTypes) resolveTag(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
id, ok := menuReferenceID(in.Reference)
if !ok || !m.tagPages() {
return nil, nil
}
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var tags []models.Tag
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
tag := tags[0]
item := resolvedMenuItem(in.Pattern, locales, tag.Name, tag.UpdatedAt, tagParams(tag), func(string) string { return tag.Name })
return &item, nil
}
// tagParams are a tag's placeholder values; tags are not translatable.
func tagParams(tag models.Tag) func(string) [][2]string {
return func(string) [][2]string {
return [][2]string{{"id", strconv.FormatUint(uint64(tag.ID), 10)}, {"slug", tag.Slug}}
}
}
func (m menuTypes) describeTag(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 tags []models.Tag
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
if err != nil {
return nil, err
}
return &records[0], nil
}
func (m menuTypes) listTags(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 tags []models.Tag
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&tags).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(tags) > limit
if more {
tags = tags[:limit]
}
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
// describeTags describes tags, always published, one locale per enabled
// locale with the same name and slug; URLs are empty without tag pages.
func (m menuTypes) describeTags(ctx context.Context, db *gorm.DB, tags []models.Tag, pattern, unprefixed string) ([]pact.ContentRecord, error) {
locales, err := loadMenuLocales(ctx, db, unprefixed)
if err != nil {
return nil, err
}
pages := m.tagPages()
out := make([]pact.ContentRecord, 0, len(tags))
for _, tag := range tags {
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(tag.ID), 10), Published: true, LastMod: copyTime(tag.UpdatedAt)}
desc := ""
if tag.Description != nil {
desc = plainText(*tag.Description)
}
for _, alt := range menuAlternates(pattern, locales, tagParams(tag), func(string) string { return tag.Name }) {
url := alt.URL
if !pages {
url = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: tag.Name, Description: desc})
}
out = append(out, rec)
}
return out, nil
}
// matchTag is the Match of journal-tag, by slug or :id; nothing matches on
// a host without tag pages.
func (m menuTypes) matchTag(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
if !m.tagPages() {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
find := func(values map[string]string, _ string, _ menuLocales) (uint, error) {
q := db.Model(&models.Tag{})
switch {
case values["slug"] != "":
q = q.Where("slug = ?", values["slug"])
case values["id"] != "":
id, ok := menuReferenceID(values["id"])
if !ok {
return 0, nil
}
q = q.Where("id = ?", id)
default:
return 0, nil
}
var ids []uint
if err := q.Order("id ASC").Limit(1).Pluck("id", &ids).Error; err != nil || len(ids) == 0 {
return 0, err
}
return ids[0], nil
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
var tags []models.Tag
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil || len(tags) == 0 {
return nil, err
}
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
tag := tags[0]
return menuAlternates(in.Pattern, locales, tagParams(tag), func(string) string { return tag.Name }), nil
}
return matchContent(ctx, db, in, find, urls)
}
// pathReading is one way to read a public path: the locale it addresses,
// the path without its locale prefix, and whether the prefix is the one the
// locale's URLs carry.
type pathReading struct {
locale string
rest string
prefixOK bool
}
// readPath lists the readings of path: with a prefix of an enabled locale
// other than the unprefixed one, with the unprefixed locale's own code as a
// prefix (stale, never exact), and as a path of the unprefixed locale.
func readPath(path string, locales menuLocales) []pathReading {
out := []pathReading{}
seg, rest := path, "/"
if trimmed := strings.TrimPrefix(path, "/"); trimmed != path {
if i := strings.IndexByte(trimmed, '/'); i >= 0 {
seg, rest = trimmed[:i], trimmed[i:]
} else {
seg = trimmed
}
for _, code := range locales.codes {
if seg == code {
out = append(out, pathReading{locale: code, rest: rest, prefixOK: code != locales.unprefixed})
}
}
}
return append(out, pathReading{locale: locales.unprefixed, rest: path, prefixOK: true})
}
// matchContent reads the path in every way readPath lists, finds the record
// its placeholder values name (find) and compares the path with the
// record's URL in the reading's locale (urls). The first exact reading
// wins, else the first that found a record.
func matchContent(ctx context.Context, db *gorm.DB, in pact.ContentMatch, find func(values map[string]string, locale string, locales menuLocales) (uint, error), urls func(id uint) ([]pact.MenuItemAlternate, error)) (*pact.ContentMatchResult, error) {
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
var fallback *pact.ContentMatchResult
for _, reading := range readPath(in.Path, locales) {
values, ok := signpost.MatchPattern(in.Pattern, reading.rest)
if !ok {
continue
}
id, err := find(values, reading.locale, locales)
if err != nil {
return nil, err
}
if id == 0 {
continue
}
alternates, err := urls(id)
if err != nil {
return nil, err
}
exact := false
for _, alt := range alternates {
if alt.Locale == reading.locale || (len(locales.codes) == 0 && alt.Locale == xDefaultLocale) {
exact = reading.prefixOK && alt.URL == in.Path
break
}
}
res := &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(id), 10), Locale: reading.locale, Exact: exact}
if exact {
return res, nil
}
if fallback == nil {
fallback = res
}
}
return fallback, nil
}
// findMatchRecord finds the record a path's values name among scope: by
// :slug, first the slug of the path's locale (translate's slug index), then
// the default slug, then a slug of any other locale; else by :id. A value
// reaches the database only as a query parameter.
func findMatchRecord(ctx context.Context, db *gorm.DB, values map[string]string, locale string, locales menuLocales, morph string, scope func(*gorm.DB) *gorm.DB) (uint, error) {
first := func(ids []uint) (uint, error) {
if len(ids) == 0 {
return 0, nil
}
var found []uint
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("id IN ?", ids).Order("id ASC").Limit(1).Pluck("id", &found).Error; err != nil || len(found) == 0 {
return 0, err
}
return found[0], nil
}
if slug := values["slug"]; slug != "" {
if locale != "" && locale != locales.defaultCode {
ids, err := slugIndexIDs(ctx, db, morph, slug, locale)
if err != nil {
return 0, err
}
if id, err := first(ids); err != nil || id != 0 {
return id, err
}
}
var hosts []uint
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("slug = ?", slug).Order("id ASC").Pluck("id", &hosts).Error; err != nil {
return 0, err
}
if id, err := first(hosts); err != nil || id != 0 {
return id, err
}
ids, err := slugIndexIDs(ctx, db, morph, slug, "")
if err != nil {
return 0, err
}
return first(ids)
}
if raw := values["id"]; raw != "" {
id, ok := menuReferenceID(raw)
if !ok {
return 0, nil
}
return first([]uint{id})
}
return 0, nil
}
// slugIndexIDs are the ids of morph records whose translated slug is slug
// in locale ("" for any locale), from translate's index table.
func slugIndexIDs(ctx context.Context, db *gorm.DB, morph, slug, locale string) ([]uint, error) {
q := db.WithContext(ctx).Table(translatemodels.Index{}.TableName()).
Where("model_type = ? AND item = ? AND value = ?", morph, "slug", slug)
if locale != "" {
q = q.Where("locale = ?", locale)
}
var raw []string
if err := q.Order("id ASC").Pluck("model_id", &raw).Error; err != nil {
return nil, err
}
ids := make([]uint, 0, len(raw))
for _, r := range raw {
if id, ok := menuReferenceID(r); ok {
ids = append(ids, id)
}
}
return ids, nil
}

View File

@@ -24,6 +24,8 @@ const (
MenuTypePost = "journal-post"
MenuTypeAllPosts = "all-journal-posts"
MenuTypeCategoryPosts = "category-journal-posts"
// MenuTypeTag is one tag; it has no PHP counterpart (Phase 32, D-102).
MenuTypeTag = "journal-tag"
)
// The phrase keys of the menu item type labels.
@@ -33,6 +35,7 @@ const (
menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
menuLabelAllPosts = "golem15.journal::lang.menuitem.all_journal_posts"
menuLabelCategoryPosts = "golem15.journal::lang.menuitem.category_journal_posts"
menuLabelTag = "golem15.journal::lang.menuitem.journal_tag"
)
// The default URL patterns of the menu item types. A host overrides them
@@ -43,6 +46,8 @@ const (
const (
DefaultPostPattern = "/blog/:slug"
DefaultCategoryPattern = "/blog/category/:slug"
// DefaultTagPattern is the tag route of the vue-journal-layer.
DefaultTagPattern = "/blog/tag/:slug"
)
// xDefaultLocale is the single alternate's locale when translate has no
@@ -54,6 +59,7 @@ const xDefaultLocale = "x-default"
var (
postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
categoryPlaceholders = []string{":slug", ":id"}
tagPlaceholders = []string{":slug", ":id"}
)
// MenuItemTypes returns the Journal menu item types, the typed successors
@@ -74,6 +80,14 @@ var (
// by title.
// - category-journal-posts: a list whose items are the published posts of
// the picked category and its descendants, ordered by title.
// - journal-tag: one tag picked by id (References lists every tag by
// 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-category, journal-post and journal-tag carry content descriptors
// (pact.ContentDescriptor, content_descriptors.go), so consumers such as
// sitemaps and metadata editors describe, page and match their records.
//
// A list type builds its items from the pattern it receives, which the
// registry layers from the item's override, the host override of the list
@@ -107,6 +121,7 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
DynamicItems: true,
References: m.categoryReferences,
Resolve: m.resolveCategory,
Content: m.categoryContent(),
},
{
Key: MenuTypeAllCategories,
@@ -145,6 +160,15 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
References: m.categoryReferences,
Resolve: m.resolveCategoryPosts,
},
{
Key: MenuTypeTag,
Label: menuLabelTag,
DefaultPattern: DefaultTagPattern,
Placeholders: append([]string(nil), tagPlaceholders...),
References: m.tagReferences,
Resolve: m.resolveTag,
Content: m.tagContent(),
},
}
}

View File

@@ -12,14 +12,14 @@ import (
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the five
// registered types, the ordered placeholder fill of PHP buildUrlFromPattern,
// 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,
// 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 five types", func(t *testing.T) {
t.Run("the six types", func(t *testing.T) {
types := MenuItemTypes(nil)
var got []string
for _, typ := range types {
@@ -35,6 +35,7 @@ func TestMenuItemTypesPatterns(t *testing.T) {
"journal-post|/blog/:slug||nest=false|dyn=false|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.journal_post",
"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",
}
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"))