feat(17-03): the other four Journal menu item types

- journal-category (category tree references, nesting returns the subtree),
  all-journal-categories (by name), all-journal-posts (published, by title)
  and category-journal-posts (category and descendants, published, by title)
- list types take the record type's DefaultPattern and set PatternFrom, and
  build their items from the layered pattern
- menuitem labels for all five types in en and pl
- README section Menu item types and API reference rows
- TestMenuItemTypesSmoke covers host and item overrides, nesting, ordering,
  the published filter and x-default
This commit is contained in:
2026-10-12 16:57:56 +02:00
parent fd1572a3a2
commit c49d216f0f
5 changed files with 535 additions and 42 deletions

View File

@@ -19,52 +19,100 @@ import (
// The Journal menu item type keys, the PHP keys of Plugin.php
// extendWinterPagesPlugin (D-04).
const (
MenuTypePost = "journal-post"
MenuTypeCategory = "journal-category"
MenuTypeAllCategories = "all-journal-categories"
MenuTypePost = "journal-post"
MenuTypeAllPosts = "all-journal-posts"
MenuTypeCategoryPosts = "category-journal-posts"
)
// The phrase keys of the menu item type labels.
const (
menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
menuLabelCategory = "golem15.journal::lang.menuitem.journal_category"
menuLabelAllCategories = "golem15.journal::lang.menuitem.all_journal_categories"
menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
menuLabelAllPosts = "golem15.journal::lang.menuitem.all_journal_posts"
menuLabelCategoryPosts = "golem15.journal::lang.menuitem.category_journal_posts"
)
// The default URL patterns of the menu item types. A host overrides them
// through the registry's menu_items.patterns config, an item through its own
// URL pattern.
// URL pattern. The list types take the pattern of the record type they list
// and name it in PatternFrom, so a host that overrides journal-post and
// journal-category changes the list types' children too.
const (
DefaultPostPattern = "/blog/:slug"
DefaultPostPattern = "/blog/:slug"
DefaultCategoryPattern = "/blog/category/:slug"
)
// xDefaultLocale is the single alternate's locale when translate has no
// enabled locale (PHP buildAlternateLinks).
const xDefaultLocale = "x-default"
// postPlaceholders are the placeholders a post URL pattern understands, in
// the order PHP getMenuTypeInfo lists them.
var postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
// The placeholders a URL pattern understands, in the order PHP
// getMenuTypeInfo lists them.
var (
postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
categoryPlaceholders = []string{":slug", ":id"}
)
// MenuItemTypes returns the Journal menu item types, the typed successors
// of the PHP pages.menuitem handlers of Post and Category (headless branch):
//
// - journal-category: one category picked from the category tree; it
// resolves to the category's URL and, with nesting, to its subtree as
// sub-items, each with its URL in the first locale only.
// - all-journal-categories: a list without a URL of its own whose items are
// every category ordered by name.
// - journal-post: one post picked by id (References lists every post by
// title); it resolves to the post's URL without a published filter.
// - all-journal-posts: a list whose items are the published posts ordered
// by title.
// - category-journal-posts: a list whose items are the published posts of
// the picked category and its descendants, ordered by title.
//
// URLs are built from the pattern the registry layered (MenuItemResolve
// Pattern) by ordered literal replacement of the placeholders, as PHP
// buildUrlFromPattern did: posts fill :id, :slug, :category (the slug of the
// post's first category by name) and, for a published post with a publish
// date, :year, :month and :day (UTC). Alternates hold one URL per enabled
// translate locale in sort_order (ties by id): the unprefixed locale
// (MenuItemResolve.UnprefixedLocale, else translate's default locale) has no
// prefix, every other locale the prefix /code, and slugs are translated per
// locale with the default value as fallback. Without an enabled locale the
// only alternate is x-default. The item URL is the first alternate's URL, as
// PHP took reset($alternateLinks).
// 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
// key, the host override of its PatternFrom record type and the default.
// Published means published, with a publish date in the past. Ties in every
// order break by id.
//
// URLs are built from the layered pattern (MenuItemResolve Pattern) by
// ordered literal replacement of the placeholders, as PHP
// buildUrlFromPattern did: categories fill :id and :slug; posts fill :id,
// :slug, :category (the slug of the post's first category by name) and, for
// a published post with a publish date, :year, :month and :day (UTC).
// Alternates hold one URL per enabled translate locale in sort_order (ties by
// id): the unprefixed locale (MenuItemResolve.UnprefixedLocale, else
// translate's default locale) has no prefix, every other locale the prefix
// /code, and slugs are translated per locale with the default value as
// fallback. Without an enabled locale the only alternate is x-default. An
// item's URL is its first alternate's URL, as PHP took reset($alternateLinks).
//
// app is asked for the booted application at request time; its *gorm.DB is
// the database the types read.
func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
m := menuTypes{app: app}
return []pact.MenuItemType{
{
Key: MenuTypeCategory,
Label: menuLabelCategory,
DefaultPattern: DefaultCategoryPattern,
Placeholders: append([]string(nil), categoryPlaceholders...),
Nesting: true,
DynamicItems: true,
References: m.categoryReferences,
Resolve: m.resolveCategory,
},
{
Key: MenuTypeAllCategories,
Label: menuLabelAllCategories,
DefaultPattern: DefaultCategoryPattern,
PatternFrom: MenuTypeCategory,
Placeholders: append([]string(nil), categoryPlaceholders...),
DynamicItems: true,
Resolve: m.resolveAllCategories,
},
{
Key: MenuTypePost,
Label: menuLabelPost,
@@ -73,6 +121,25 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
References: m.postReferences,
Resolve: m.resolvePost,
},
{
Key: MenuTypeAllPosts,
Label: menuLabelAllPosts,
DefaultPattern: DefaultPostPattern,
PatternFrom: MenuTypePost,
Placeholders: append([]string(nil), postPlaceholders...),
DynamicItems: true,
Resolve: m.resolveAllPosts,
},
{
Key: MenuTypeCategoryPosts,
Label: menuLabelCategoryPosts,
DefaultPattern: DefaultPostPattern,
PatternFrom: MenuTypePost,
Placeholders: append([]string(nil), postPlaceholders...),
DynamicItems: true,
References: m.categoryReferences,
Resolve: m.resolveCategoryPosts,
},
}
}
@@ -197,6 +264,260 @@ func resolvePostItems(ctx context.Context, db *gorm.DB, posts []models.Post, in
return out, nil
}
// resolveAllPosts resolves all-journal-posts: no URL, the published posts
// ordered by title as items.
func (m menuTypes) resolveAllPosts(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var posts []models.Post
if err := publishedPosts(db).Order("title ASC, id ASC").Find(&posts).Error; err != nil {
return nil, fmt.Errorf("golem15.journal: menu item posts: %w", err)
}
items, err := resolvePostItems(ctx, db, posts, in)
if err != nil {
return nil, err
}
return &pact.ResolvedMenuItem{Items: items}, nil
}
// resolveCategoryPosts resolves category-journal-posts: no URL, the
// published posts of the referenced category and its descendants ordered by
// title as items. An unknown category resolves to nothing.
func (m menuTypes) resolveCategoryPosts(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
id, ok := menuReferenceID(in.Reference)
if !ok {
return nil, nil
}
db, err := m.db(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, fmt.Errorf("golem15.journal: menu item category %d: %w", id, err)
}
if len(cats) == 0 {
return nil, nil
}
ids, err := categoryAndDescendantIDs(db, cats[0])
if err != nil {
return nil, err
}
var posts []models.Post
err = publishedPosts(db).
Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN ?)", ids).
Order("title ASC, id ASC").
Find(&posts).Error
if err != nil {
return nil, fmt.Errorf("golem15.journal: menu item category posts: %w", err)
}
items, err := resolvePostItems(ctx, db, posts, in)
if err != nil {
return nil, err
}
return &pact.ResolvedMenuItem{Items: items}, nil
}
// publishedPosts restricts db to published posts: published, with a publish
// date in the past (PHP scopeIsPublished, the public API's rule).
func publishedPosts(db *gorm.DB) *gorm.DB {
return db.Where("published = ? AND published_at IS NOT NULL AND published_at < ?", true, time.Now())
}
// categoryAndDescendantIDs is the category's id and the ids of every
// category inside its nested-set range (PHP getAllChildrenAndSelf).
func categoryAndDescendantIDs(db *gorm.DB, c models.Category) ([]uint, error) {
ids := []uint{c.ID}
if c.NestLeft == nil || c.NestRight == nil {
return ids, nil
}
var kids []uint
if err := db.Model(&models.Category{}).
Where("nest_left > ? AND nest_right < ?", *c.NestLeft, *c.NestRight).
Order("nest_left ASC, id ASC").
Pluck("id", &kids).Error; err != nil {
return nil, fmt.Errorf("golem15.journal: menu item category descendants: %w", err)
}
return append(ids, kids...), nil
}
// categoryTree is the categories nested by parent_id in nested-set order
// (PHP getNested): a category whose parent is not among them is a root.
type categoryTree struct {
cats []models.Category
index map[uint]int
roots []int
children map[uint][]int
}
// loadCategoryTree reads every category ordered by nest_left, then name and
// id.
func loadCategoryTree(ctx context.Context, db *gorm.DB) (categoryTree, error) {
var cats []models.Category
if err := db.WithContext(ctx).Order("nest_left ASC NULLS LAST, name ASC, id ASC").Find(&cats).Error; err != nil {
return categoryTree{}, fmt.Errorf("golem15.journal: menu item categories: %w", err)
}
t := categoryTree{cats: cats, index: map[uint]int{}, children: map[uint][]int{}}
for i, c := range cats {
t.index[c.ID] = i
}
for i, c := range cats {
if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID {
if _, ok := t.index[*c.ParentID]; ok {
t.children[*c.ParentID] = append(t.children[*c.ParentID], i)
continue
}
}
t.roots = append(t.roots, i)
}
return t, nil
}
func (t categoryTree) ids() []uint {
out := make([]uint, 0, len(t.cats))
for _, c := range t.cats {
out = append(out, c.ID)
}
return out
}
// references is the tree as menu item references: value id, label name.
func (t categoryTree) references(nodes []int) []pact.MenuItemReference {
out := make([]pact.MenuItemReference, 0, len(nodes))
for _, i := range nodes {
c := t.cats[i]
out = append(out, pact.MenuItemReference{
Value: strconv.FormatUint(uint64(c.ID), 10),
Label: c.Name,
Items: t.references(t.children[c.ID]),
})
}
return out
}
// categoryReferences lists the category tree (PHP listSubCategoryOptions).
func (m menuTypes) categoryReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
db, err := m.db(ctx)
if err != nil {
return nil, err
}
t, err := loadCategoryTree(ctx, db)
if err != nil {
return nil, err
}
return t.references(t.roots), nil
}
// categoryItems resolves categories with their pattern URLs and
// alternates.
type categoryItems struct {
pattern string
locales menuLocales
tr menuTranslations
}
func newCategoryItems(ctx context.Context, db *gorm.DB, in pact.MenuItemResolve, ids []uint) (categoryItems, error) {
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return categoryItems{}, err
}
tr, err := loadMenuTranslations(ctx, db, models.Category{}.MorphName(), ids, locales)
if err != nil {
return categoryItems{}, err
}
return categoryItems{pattern: in.Pattern, locales: locales, tr: tr}, nil
}
func (ci categoryItems) item(c models.Category) pact.ResolvedMenuItem {
params := func(locale string) [][2]string {
slug := c.Slug
if locale != "" {
slug = ci.tr.value(locale, c.ID, "slug", slug)
}
return [][2]string{{"id", strconv.FormatUint(uint64(c.ID), 10)}, {"slug", slug}}
}
title := func(locale string) string {
if locale == "" {
return c.Name
}
return ci.tr.value(locale, c.ID, "name", c.Name)
}
return resolvedMenuItem(ci.pattern, ci.locales, c.Name, c.UpdatedAt, params, title)
}
// subtree resolves the children of id, each with its own children.
func (ci categoryItems) subtree(t categoryTree, id uint) []pact.ResolvedMenuItem {
nodes := t.children[id]
out := make([]pact.ResolvedMenuItem, 0, len(nodes))
for _, i := range nodes {
c := t.cats[i]
item := ci.item(c)
if kids := ci.subtree(t, c.ID); len(kids) > 0 {
item.Items = kids
}
out = append(out, item)
}
return out
}
// resolveCategory resolves journal-category: the category's URL, and with
// nesting its subtree as items. An unknown category resolves to nothing.
func (m menuTypes) resolveCategory(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
id, ok := menuReferenceID(in.Reference)
if !ok {
return nil, nil
}
db, err := m.db(ctx)
if err != nil {
return nil, err
}
t, err := loadCategoryTree(ctx, db)
if err != nil {
return nil, err
}
i, ok := t.index[id]
if !ok {
return nil, nil
}
ci, err := newCategoryItems(ctx, db, in, t.ids())
if err != nil {
return nil, err
}
item := ci.item(t.cats[i])
if in.Nesting {
item.Items = ci.subtree(t, id)
}
return &item, nil
}
// resolveAllCategories resolves all-journal-categories: no URL, every
// category ordered by name as items.
func (m menuTypes) resolveAllCategories(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var cats []models.Category
if err := db.Order("name ASC, id ASC").Find(&cats).Error; err != nil {
return nil, fmt.Errorf("golem15.journal: menu item categories: %w", err)
}
ids := make([]uint, 0, len(cats))
for _, c := range cats {
ids = append(ids, c.ID)
}
ci, err := newCategoryItems(ctx, db, in, ids)
if err != nil {
return nil, err
}
items := make([]pact.ResolvedMenuItem, 0, len(cats))
for _, c := range cats {
items = append(items, ci.item(c))
}
return &pact.ResolvedMenuItem{Items: items}, nil
}
// postURLParams are a post's placeholder values in PHP
// getHeadlessUrlParams order: id, slug, category, then year, month and day
// for a published post with a publish date.