feat(17-03): register the journal-post menu item type

- Plugin implements pact.HasMenuItemTypes with a lazy app accessor
- journal-post: references by title, resolve fills the layered pattern by
  ordered replacement (id, slug, category, year, month, day) and builds the
  ordered per-locale alternates with translated slugs (x-default without
  enabled locales)
- menuitem.journal_post labels in en and pl
- TestMenuItemTypesSmoke: host override, item override, sort_order alternates
This commit is contained in:
2026-10-12 16:53:09 +02:00
parent 58851dd101
commit fd1572a3a2
5 changed files with 615 additions and 0 deletions

439
classes/menu_item_types.go Normal file
View File

@@ -0,0 +1,439 @@
package classes
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"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/pact"
"gorm.io/gorm"
)
// The Journal menu item type keys, the PHP keys of Plugin.php
// extendWinterPagesPlugin (D-04).
const (
MenuTypePost = "journal-post"
)
// The phrase keys of the menu item type labels.
const (
menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
)
// 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.
const (
DefaultPostPattern = "/blog/: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"}
// MenuItemTypes returns the Journal menu item types, the typed successors
// of the PHP pages.menuitem handlers of Post and Category (headless branch):
//
// - journal-post: one post picked by id (References lists every post by
// title); it resolves to the post's URL without a published filter.
//
// 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).
//
// 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: MenuTypePost,
Label: menuLabelPost,
DefaultPattern: DefaultPostPattern,
Placeholders: append([]string(nil), postPlaceholders...),
References: m.postReferences,
Resolve: m.resolvePost,
},
}
}
// menuTypes carries the lazy app accessor the type closures share.
type menuTypes struct {
app func() *backpack.App
}
func (m menuTypes) db(ctx context.Context) (*gorm.DB, error) {
if m.app == nil {
return nil, errors.New("golem15.journal: menu item types: app is not available")
}
app := m.app()
if app == nil {
return nil, errors.New("golem15.journal: menu item types: app is not booted")
}
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("golem15.journal: menu item types: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
return db.WithContext(ctx), nil
}
// postReferences lists every post ordered by title (PHP getMenuTypeInfo),
// ties by id.
func (m menuTypes) postReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var posts []models.Post
if err := db.Select("id", "title").Order("title ASC, id ASC").Find(&posts).Error; err != nil {
return nil, fmt.Errorf("golem15.journal: menu item post references: %w", err)
}
refs := make([]pact.MenuItemReference, 0, len(posts))
for _, p := range posts {
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(p.ID), 10), Label: p.Title})
}
return refs, nil
}
// resolvePost resolves one journal-post item. An unknown or missing
// reference resolves to nothing, as PHP returned null.
func (m menuTypes) resolvePost(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 posts []models.Post
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil {
return nil, fmt.Errorf("golem15.journal: menu item post %d: %w", id, err)
}
if len(posts) == 0 {
return nil, nil
}
items, err := resolvePostItems(ctx, db, posts, in)
if err != nil {
return nil, err
}
return &items[0], nil
}
// resolvePostItems resolves posts to one item each, in the given order.
func resolvePostItems(ctx context.Context, db *gorm.DB, posts []models.Post, in pact.MenuItemResolve) ([]pact.ResolvedMenuItem, error) {
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
ids := make([]uint, 0, len(posts))
for _, p := range posts {
ids = append(ids, p.ID)
}
postTr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), ids, locales)
if err != nil {
return nil, err
}
firsts, err := firstPostCategories(ctx, db, ids)
if err != nil {
return nil, err
}
catIDs := []uint{}
for _, c := range firsts {
catIDs = append(catIDs, c.ID)
}
catTr, err := loadMenuTranslations(ctx, db, models.Category{}.MorphName(), catIDs, locales)
if err != nil {
return nil, err
}
out := make([]pact.ResolvedMenuItem, 0, len(posts))
for i := range posts {
p := &posts[i]
cat, hasCat := firsts[p.ID]
params := func(locale string) [][2]string {
slug := p.Slug
catSlug := ""
if hasCat {
catSlug = cat.Slug
}
if locale != "" {
slug = postTr.value(locale, p.ID, "slug", slug)
if hasCat {
catSlug = catTr.value(locale, cat.ID, "slug", catSlug)
}
}
return postURLParams(p, slug, catSlug)
}
title := func(locale string) string {
if locale == "" {
return p.Title
}
return postTr.value(locale, p.ID, "title", p.Title)
}
out = append(out, resolvedMenuItem(in.Pattern, locales, p.Title, p.UpdatedAt, params, title))
}
return out, 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.
func postURLParams(p *models.Post, slug, categorySlug string) [][2]string {
params := [][2]string{
{"id", strconv.FormatUint(uint64(p.ID), 10)},
{"slug", slug},
{"category", categorySlug},
}
if p.Published && p.PublishedAt != nil {
at := p.PublishedAt.UTC()
params = append(params,
[2]string{"year", fmt.Sprintf("%04d", at.Year())},
[2]string{"month", fmt.Sprintf("%02d", int(at.Month()))},
[2]string{"day", fmt.Sprintf("%02d", at.Day())},
)
}
return params
}
// firstPostCategories maps each post id to its first category by name (the
// PHP categories relation's order), ties by id.
func firstPostCategories(ctx context.Context, db *gorm.DB, postIDs []uint) (map[uint]models.Category, error) {
out := map[uint]models.Category{}
if len(postIDs) == 0 {
return out, nil
}
var rows []struct {
PostID uint
models.Category
}
err := db.WithContext(ctx).
Table("golem15_journal_categories AS c").
Select("pc.post_id AS post_id, c.*").
Joins("JOIN golem15_journal_posts_categories AS pc ON pc.category_id = c.id").
Where("pc.post_id IN ?", postIDs).
Order("c.name ASC, c.id ASC").
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("golem15.journal: menu item post categories: %w", err)
}
for _, r := range rows {
if _, seen := out[r.PostID]; !seen {
out[r.PostID] = r.Category
}
}
return out, nil
}
// resolvedMenuItem builds one resolved item: its alternates from pattern
// per locale, its URL the first alternate's URL (else the pattern filled
// with the default values), its title and mtime.
func resolvedMenuItem(pattern string, locales menuLocales, title string, mtime *time.Time, params func(locale string) [][2]string, titleIn func(locale string) string) pact.ResolvedMenuItem {
alternates := menuAlternates(pattern, locales, params, titleIn)
url := ""
if len(alternates) > 0 {
url = alternates[0].URL
}
if url == "" {
url = fillMenuPattern(pattern, params(""))
}
item := pact.ResolvedMenuItem{
Title: title,
URL: url,
Alternates: alternates,
}
if mtime != nil {
t := *mtime
item.MTime = &t
}
return item
}
// menuAlternates is PHP buildAlternateLinks: x-default alone without an
// enabled locale, else one URL per enabled locale in order, prefixed with
// /code except for the unprefixed locale.
func menuAlternates(pattern string, locales menuLocales, params func(locale string) [][2]string, title func(locale string) string) []pact.MenuItemAlternate {
if len(locales.codes) == 0 {
return []pact.MenuItemAlternate{{
Locale: xDefaultLocale,
URL: fillMenuPattern(pattern, params("")),
Title: title(""),
}}
}
out := make([]pact.MenuItemAlternate, 0, len(locales.codes))
for _, code := range locales.codes {
prefix := ""
if code != locales.unprefixed {
prefix = "/" + code
}
out = append(out, pact.MenuItemAlternate{
Locale: code,
URL: prefix + fillMenuPattern(pattern, params(code)),
Title: title(code),
})
}
return out
}
// fillMenuPattern replaces each ":key" in pattern with its value, in params
// order, as PHP buildUrlFromPattern's str_replace loop did.
func fillMenuPattern(pattern string, params [][2]string) string {
url := pattern
for _, kv := range params {
url = strings.ReplaceAll(url, ":"+kv[0], kv[1])
}
return url
}
// menuReferenceID parses an item reference as a record id.
func menuReferenceID(ref string) (uint, bool) {
n, err := strconv.ParseUint(strings.TrimSpace(ref), 10, 32)
if err != nil || n == 0 {
return 0, false
}
return uint(n), true
}
// menuLocales is the enabled translate locales in sort_order (ties by id),
// the default locale whose values live on the host columns, and the locale
// whose URLs carry no prefix.
type menuLocales struct {
codes []string
defaultCode string
unprefixed string
}
// loadMenuLocales reads the enabled locales. unprefixed names the
// unprefixed locale; "" means translate's default locale.
func loadMenuLocales(ctx context.Context, db *gorm.DB, unprefixed string) (menuLocales, error) {
var rows []translatemodels.Locale
err := db.WithContext(ctx).
Where("is_enabled = ?", true).
Order("sort_order ASC, id ASC").
Find(&rows).Error
if err != nil {
return menuLocales{}, fmt.Errorf("golem15.journal: menu item locales: %w", err)
}
out := menuLocales{unprefixed: strings.TrimSpace(unprefixed)}
for _, l := range rows {
out.codes = append(out.codes, l.Code)
if l.IsDefault && out.defaultCode == "" {
out.defaultCode = l.Code
}
}
if out.unprefixed == "" {
out.unprefixed = out.defaultCode
}
return out, nil
}
// menuTranslations holds the translated attributes of a set of records:
// locale, then record id, then field.
type menuTranslations struct {
defaultCode string
data map[string]map[uint]map[string]any
}
// value is the record's field in locale with PHP's fallback: the default
// locale and a missing or falsy translation read the host value.
func (t menuTranslations) value(locale string, id uint, field, host string) string {
if locale == "" || locale == t.defaultCode {
return host
}
v, ok := t.data[locale][id][field]
if !ok || !phpTruthy(v) {
return host
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
// loadMenuTranslations reads the translate attribute rows of the records
// ids of morph in every non-default enabled locale with one query.
func loadMenuTranslations(ctx context.Context, db *gorm.DB, morph string, ids []uint, locales menuLocales) (menuTranslations, error) {
out := menuTranslations{defaultCode: locales.defaultCode, data: map[string]map[uint]map[string]any{}}
var codes []string
for _, c := range locales.codes {
if c != locales.defaultCode {
codes = append(codes, c)
}
}
if len(ids) == 0 || len(codes) == 0 {
return out, nil
}
keys := make([]string, 0, len(ids))
for _, id := range ids {
keys = append(keys, strconv.FormatUint(uint64(id), 10))
}
var rows []translatemodels.Attribute
err := db.WithContext(ctx).
Where("model_type = ? AND locale IN ? AND model_id IN ?", morph, codes, keys).
Order("id ASC").
Find(&rows).Error
if err != nil {
return out, fmt.Errorf("golem15.journal: menu item translations: %w", err)
}
for _, r := range rows {
if r.ModelID == nil || r.AttributeData == nil || strings.TrimSpace(*r.AttributeData) == "" {
continue
}
id, err := strconv.ParseUint(strings.TrimSpace(*r.ModelID), 10, 32)
if err != nil {
continue
}
var fields map[string]any
if err := json.Unmarshal([]byte(*r.AttributeData), &fields); err != nil {
continue
}
if out.data[r.Locale] == nil {
out.data[r.Locale] = map[uint]map[string]any{}
}
if _, seen := out.data[r.Locale][uint(id)]; !seen {
out.data[r.Locale][uint(id)] = fields
}
}
return out, nil
}
// phpTruthy is PHP's boolean cast of a decoded JSON value, the test PHP
// hasTranslation applied before it used a translation.
func phpTruthy(v any) bool {
switch t := v.(type) {
case nil:
return false
case string:
return t != "" && t != "0"
case bool:
return t
case float64:
return t != 0
case []any:
return len(t) > 0
case map[string]any:
return len(t) > 0
}
return true
}

View File

@@ -131,6 +131,8 @@ tag:
delete_confirm: Delete this tag?
delete_success: Successfully deleted those tags.
return_to_tags: Return to the journal tag list
menuitem:
journal_post: Journal post
scopes:
journal: Journal
journal_description: Create, edit, publish and delete posts as you, within your Journal permissions.

View File

@@ -83,6 +83,8 @@ category:
delete_success: Kategorie zostały pomyślnie usunięte.
return_to_categories: Wróć do listy kategorii
reorder: Zmień kolejność kategorii
menuitem:
journal_post: Wpis (Journal)
scopes:
journal: Journal
journal_description: Twórz, edytuj, publikuj i usuwaj posty jako Ty, w ramach swoich uprawnień Journal.

22
menu_item_types.go Normal file
View File

@@ -0,0 +1,22 @@
package journal
import (
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// MenuItemTypes registers the Journal menu item types under their PHP keys
// (classes.MenuItemTypes) with a lazy accessor of the booted app, so menus
// and sitemaps can address Journal posts and categories through the
// framework's menu item type registry.
func (p *Plugin) MenuItemTypes() []pact.MenuItemType {
return classes.MenuItemTypes(func() *backpack.App {
if p == nil {
return nil
}
return p.app
})
}
var _ pact.HasMenuItemTypes = (*Plugin)(nil)

View File

@@ -0,0 +1,150 @@
package journal_test
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/signpost"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
// bootMenuItemTypes boots translate and Journal on a fresh database with
// extra config files (name to YAML) and returns the app, the database and
// the signpost registry.
func bootMenuItemTypes(t *testing.T, files map[string]string) (*backpack.App, *gorm.DB, *signpost.Registry) {
t.Helper()
sqlDB := dedicatedDB(t, "journal_menu_"+strconv.FormatInt(time.Now().UnixNano(), 10))
gdb, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
files["app.yaml"] = "name: journal-menu\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret},
})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
if err != nil {
t.Fatal(err)
}
if err := lagoon.Migrate(gdb, plugins); err != nil {
t.Fatal(err)
}
reg, ok := app.Lookup[*signpost.Registry]()
if !ok || reg == nil {
t.Fatal("signpost registry is not published")
}
return app, gdb, reg
}
func menuPost(t *testing.T, gdb *gorm.DB, title, slug string, published bool, at *time.Time) models.Post {
t.Helper()
p := models.Post{Title: title, Slug: slug, Content: "x", Published: published, PublishedAt: at}
if err := gdb.Create(&p).Error; err != nil {
t.Fatal(err)
}
return p
}
func altString(alts []pact.MenuItemAlternate) string {
parts := make([]string, 0, len(alts))
for _, a := range alts {
parts = append(parts, a.Locale+"="+a.URL)
}
return strings.Join(parts, " ")
}
// TestMenuItemTypesSmoke boots Journal with translate, checks the
// registered types and resolves them through the signpost registry with host
// pattern overrides and ordered alternates.
func TestMenuItemTypesSmoke(t *testing.T) {
requirePostgres(t)
ctx := context.Background()
_, gdb, reg := bootMenuItemTypes(t, map[string]string{
"menu_items.yaml": "patterns:\n journal-post: /news/:slug\n",
})
typ, ok := reg.Type(classes.MenuTypePost)
if !ok {
t.Fatalf("%s is not registered", classes.MenuTypePost)
}
if typ.DefaultPattern != classes.DefaultPostPattern || typ.References == nil {
t.Fatalf("journal-post type = %+v", typ)
}
// pl sorts before the default en, so the first alternate (the item
// URL, PHP reset()) is the prefixed pl URL.
if err := gdb.Exec(`UPDATE golem15_translate_locales SET sort_order = CASE code WHEN 'pl' THEN 1 WHEN 'en' THEN 2 ELSE sort_order END`).Error; err != nil {
t.Fatal(err)
}
at := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
post := menuPost(t, gdb, "Hello", "hello", true, &at)
if err := translate.SetTranslated(ctx, gdb, &post, "slug", "pl", "witaj"); err != nil {
t.Fatal(err)
}
menuPost(t, gdb, "Another", "another", false, nil)
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypePost)
if err != nil || !refs.Applies || len(refs.Options) != 2 || refs.Options[0].Label != "Another" || refs.Options[1].Label != "Hello" {
t.Fatalf("post references = %+v err=%v", refs, err)
}
got, err := reg.Resolve(ctx, classes.MenuTypePost, strconv.FormatUint(uint64(post.ID), 10), "", false, "")
if err != nil || got == nil {
t.Fatalf("resolve journal-post: %+v %v", got, err)
}
if want := "pl=/pl/news/witaj en=/news/hello"; altString(got.Alternates) != want {
t.Fatalf("alternates = %q, want %q", altString(got.Alternates), want)
}
if got.URL != "/pl/news/witaj" || got.Title != "Hello" || got.MTime == nil {
t.Fatalf("resolved = %+v", got)
}
// The item override wins over the host override; the unprefixed locale
// comes from the caller.
got, err = reg.Resolve(ctx, classes.MenuTypePost, strconv.FormatUint(uint64(post.ID), 10), "/:year/:month/:day/:slug", false, "pl")
if err != nil || got == nil {
t.Fatalf("resolve with item pattern: %+v %v", got, err)
}
if want := "pl=/2026/03/04/witaj en=/en/2026/03/04/hello"; altString(got.Alternates) != want {
t.Fatalf("alternates = %q, want %q", altString(got.Alternates), want)
}
// An unknown reference resolves to nothing.
if got, err := reg.Resolve(ctx, classes.MenuTypePost, "999999", "", false, ""); err != nil || got != nil {
t.Fatalf("unknown post = %+v %v", got, err)
}
}