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

@@ -20,6 +20,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`, `mltextarea` and the plugin's own `mlblogeditor` field type for content (see [Admin: Blog Editor field types](#admin-blog-editor-field-types)); an admin save stores the sanitized HTML in both `content` and `content_html`. `classes.FormatHTML` regenerates `content_html` on the other write paths (`journal:import-posts`, Journal API writes). It renders markdown and passes raw HTML in `content` through the renderer (Winter parity), then sanitizes the result with a `bluemonday` allow-list: bluemonday's UGC policy plus `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` styles, goldmark's footnote roles, `dir="auto"`, two pasted-transcript `data-message-*` attributes, and exactly four rules for the Blog Editor's own output (Phase 16.2): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, and `start` matching `^[0-9]+$` on `ol`. No iframe, other data attribute, style property or URL scheme is allowed for the editor; `TestEditorOutputPassesPolicy` checks every entry of the editor corpus against the policy. Like Winter's `Html::clean`, it strips what the policy does not allow instead of refusing the save: script, style, iframe, object, embed, form, base and meta elements, comments, event handler attributes, and URLs other than http, https, mailto and relative ones. HTML the policy keeps whole is stored byte for byte, so imported bodies survive a save. Admin saves strip the same way and name what they removed (`classes.SanitizeHTMLReport`). A tag that repeats an attribute is never kept whole: the sanitized output, which keeps the first value, is stored. On the API and import paths `content` itself is stored as submitted. Translated `content_html` goes through the same policy on the API and `journal:import-posts` write paths (`classes.FormatTranslation`): when the translation carries a non-blank `content`, `content_html` is rendered from it and a supplied `content_html` is replaced, as the host column is always derived; a translation with only `content_html` is sanitized with `classes.SanitizeHTML`. Winter stored a supplied translated `content_html` verbatim. The API response shapes and status codes are unchanged.
- **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin with the `blogeditor` field type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`.
- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) with the framework's menu item type registry, so menus and sitemaps can point at posts and categories. See [menu item types](#menu-item-types).
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
@@ -231,6 +232,36 @@ The public Journal API is unchanged: `POST` and `PUT /_journal/api/v1/posts` sti
Editor images are uploaded by the admin through the field's upload route and stored by `classes.StoreMedia`, the same storage as `POST /_journal/api/v1/media/upload`: the key is `/journal/<uploadFolder>/<name>` with the same numbered suffix when the name is taken and the same public URL. An editor upload must be a JPEG, PNG, GIF or WebP file whose first bytes sniff as the type its extension names (no SVG), at most 10240 KiB. Its name is lowercased and every character outside `a-z`, `0-9`, `.`, `_` and `-` becomes `-`, so the inserted `src` needs no escaping. The public upload route keeps its own naming, request fields, response and messages.
### Menu item types
`journal.Plugin.MenuItemTypes` (built by `classes.MenuItemTypes`) registers the Journal types under the PHP keys of the WinterCMS plugin's `pages.menuitem` handlers. A menu or sitemap plugin offers them as item types and resolves its items through the registry (`signpost` in the framework), which hands each type the URL pattern it layered: the item's own pattern, else the host's override of the type, else, for a list type, the host's override of the record type it names in `PatternFrom`, else the type's default.
| Key | Picks | Default pattern | `PatternFrom` | Resolves to |
|-----|-------|-----------------|---------------|-------------|
| `journal-category` | A category from the category tree | `/blog/category/:slug` | | The category's URL; with nesting, its subcategories as sub-items, each once with its URL. |
| `all-journal-categories` | Nothing | `/blog/category/:slug` | `journal-category` | No URL of its own; every category ordered by name as items. |
| `journal-post` | A post (listed by title) | `/blog/:slug` | | The post's URL, published or not. |
| `all-journal-posts` | Nothing | `/blog/:slug` | `journal-post` | No URL of its own; the published posts ordered by title as items. |
| `category-journal-posts` | A category from the category tree | `/blog/:slug` | `journal-post` | No URL of its own; the published posts of the category and its descendants ordered by title as items. |
Published means published with a publish date in the past, the public API's rule. Ties in every order break by id.
Placeholders are replaced literally, in order, like the PHP `buildUrlFromPattern`. A category pattern takes `:id` and `:slug`. A post pattern takes `: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).
Every resolved record carries alternates, one per enabled translate locale in `sort_order` (ties by id). The unprefixed locale (the caller's, for example a sitemap's frontend default locale, else translate's default locale) gets no prefix; every other locale gets `/<code>`. Slugs and titles are translated per locale and fall back to the default value. Without an enabled locale the only alternate is `x-default`. A record's URL is its first alternate's URL, as in PHP.
A host changes the URLs in its config file `config/menu_items.yaml` (YAML only, because the keys contain dashes). Overriding the two record types is enough; the list types follow:
```yaml
patterns:
journal-post: /blog/:slug
journal-category: /:slug
```
A host may also override a list key itself (for example `all-journal-posts: /articles/:slug`) to give its items a different pattern than the single-record type, and a menu or sitemap item can carry its own pattern.
Category content stays on the Blog Editor (`blogeditor`) field type; it does not move to the framework's `richeditor`.
## Configuration
### Site cache purge
@@ -264,6 +295,7 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
| `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). |
| `journal.Plugin.FormFieldTypes` | Registers the `blogeditor` and `mlblogeditor` admin field types. |
| `journal.Plugin.MenuItemTypes` | Registers the five Journal menu item types (`pact.HasMenuItemTypes`). |
| `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). |
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
@@ -283,6 +315,9 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
| `updates.All` | gormigrate steps registered by this plugin. |
| `classes.BlogEditorTypes` | The `blogeditor` and `mlblogeditor` field types: Decode through `SanitizeHTMLReport` with removal notices, Load with the markdown fallback, list text and the image upload. |
| `classes.BlogEditorElement` | The editor's custom element tag, `golem15-journal-blogeditor`. |
| `classes.MenuItemTypes` | The Journal menu item types with their references and resolvers (see [menu item types](#menu-item-types)). |
| `classes.MenuTypeCategory`, `classes.MenuTypeAllCategories`, `classes.MenuTypePost`, `classes.MenuTypeAllPosts`, `classes.MenuTypeCategoryPosts` | The type keys `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts` and `category-journal-posts`. |
| `classes.DefaultPostPattern`, `classes.DefaultCategoryPattern` | The default patterns `/blog/:slug` and `/blog/category/:slug`. |
| `classes.LooksLikeHTML` | Whether stored content is HTML: its first non-space character is `<`. |
| `classes.StoreMedia` | Stores an uploaded image under `/journal/<folder>/<name>` and returns its `StoredMedia`; shared by the public upload route and the editor upload. |
| `classes.StoredMedia` | A stored media object's public `URL` and `Path`. |
@@ -319,7 +354,7 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex
## Dependencies
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf.
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf; signpost in tests.
- Compiled plugin: `golem15.translate`.
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`.
- Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`.
@@ -334,6 +369,8 @@ Postgres integration smokes start Docker unless `-short`. Missing Docker is a fa
Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controllers/api`) maps resolver errors to the PHP bodies; `TestJournalTokenTracer` and `TestJournalTokenExpansion` (root package, Postgres) mint tokens through the admin account API and exercise write, read-only, unrelated, revoked, featured-image and code-declared-role paths.
`TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule.
The site cache purge has unit tests in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache`) that point the plugin at a local receiver and check one authorized request after each commit.
The Blog Editor has its own suite and drift gate:

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.

View File

@@ -132,7 +132,11 @@ tag:
delete_success: Successfully deleted those tags.
return_to_tags: Return to the journal tag list
menuitem:
journal_category: Journal category
all_journal_categories: All journal categories
journal_post: Journal post
all_journal_posts: All journal posts
category_journal_posts: Journal category posts
scopes:
journal: Journal
journal_description: Create, edit, publish and delete posts as you, within your Journal permissions.

View File

@@ -84,7 +84,11 @@ category:
return_to_categories: Wróć do listy kategorii
reorder: Zmień kolejność kategorii
menuitem:
journal_category: Kategoria (Journal)
all_journal_categories: Wszystkie kategorie (Journal)
journal_post: Wpis (Journal)
all_journal_posts: Wszystkie wpisy (Journal)
category_journal_posts: Wpisy kategorii (Journal)
scopes:
journal: Journal
journal_description: Twórz, edytuj, publikuj i usuwaj posty jako Ty, w ramach swoich uprawnień Journal.

View File

@@ -87,22 +87,61 @@ func altString(alts []pact.MenuItemAlternate) string {
return strings.Join(parts, " ")
}
// TestMenuItemTypesSmoke boots Journal with translate, checks the
func menuCategory(t *testing.T, gdb *gorm.DB, name, slug string, parent *uint, left, right int) models.Category {
t.Helper()
c := models.Category{Name: name, Slug: slug, ParentID: parent, NestLeft: &left, NestRight: &right}
if err := gdb.Create(&c).Error; err != nil {
t.Fatal(err)
}
return c
}
func attachCategory(t *testing.T, gdb *gorm.DB, post models.Post, cat models.Category) {
t.Helper()
if err := gdb.Exec(`INSERT INTO golem15_journal_posts_categories (post_id, category_id) VALUES (?, ?)`, post.ID, cat.ID).Error; err != nil {
t.Fatal(err)
}
}
func itemURLs(items []pact.ResolvedMenuItem) string {
parts := make([]string, 0, len(items))
for _, it := range items {
parts = append(parts, it.Title+"="+it.URL)
}
return strings.Join(parts, " ")
}
func id(n uint) string { return strconv.FormatUint(uint64(n), 10) }
// TestMenuItemTypesSmoke boots Journal with translate, checks the five
// registered types and resolves them through the signpost registry with host
// pattern overrides and ordered alternates.
// pattern overrides of the record types, item 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",
"menu_items.yaml": "patterns:\n journal-category: /:slug\n journal-post: /p/:slug\n",
})
typ, ok := reg.Type(classes.MenuTypePost)
if !ok {
t.Fatalf("%s is not registered", classes.MenuTypePost)
wantFrom := map[string]string{
classes.MenuTypeCategory: "",
classes.MenuTypeAllCategories: classes.MenuTypeCategory,
classes.MenuTypePost: "",
classes.MenuTypeAllPosts: classes.MenuTypePost,
classes.MenuTypeCategoryPosts: classes.MenuTypePost,
}
if typ.DefaultPattern != classes.DefaultPostPattern || typ.References == nil {
t.Fatalf("journal-post type = %+v", typ)
for key, from := range wantFrom {
typ, ok := reg.Type(key)
if !ok {
t.Fatalf("%s is not registered", key)
}
if !strings.HasPrefix(typ.DefaultPattern, "/") || typ.PatternFrom != from || typ.Resolve == nil {
t.Fatalf("%s type = %+v", key, typ)
}
}
if typ, _ := reg.Type(classes.MenuTypeAllPosts); typ.References != nil {
t.Fatal("all-journal-posts must take no reference")
}
// pl sorts before the default en, so the first alternate (the item
@@ -110,41 +149,129 @@ func TestMenuItemTypesSmoke(t *testing.T) {
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)
// The migrations seed a default category; the fixture owns the tree.
if err := gdb.Exec(`DELETE FROM golem15_journal_categories`).Error; err != nil {
t.Fatal(err)
}
beta := menuCategory(t, gdb, "Beta", "beta", nil, 1, 4)
alpha := menuCategory(t, gdb, "Alpha", "alpha", &beta.ID, 2, 3)
if err := translate.SetTranslated(ctx, gdb, &alpha, "slug", "pl", "alfa"); err != nil {
t.Fatal(err)
}
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
post := menuPost(t, gdb, "Hello", "hello", true, &past)
if err := translate.SetTranslated(ctx, gdb, &post, "slug", "pl", "witaj"); err != nil {
t.Fatal(err)
}
menuPost(t, gdb, "Another", "another", false, nil)
attachCategory(t, gdb, post, alpha)
zed := menuPost(t, gdb, "Zed", "zed", true, &past)
attachCategory(t, gdb, zed, beta)
draft := menuPost(t, gdb, "Another", "another", false, nil)
attachCategory(t, gdb, draft, beta)
// journal-post: references by title, host override, item override.
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" {
if err != nil || !refs.Applies || len(refs.Options) != 3 || 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, "")
got, err := reg.Resolve(ctx, classes.MenuTypePost, id(post.ID), "", 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 {
if want := "pl=/pl/p/witaj en=/p/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 {
if got.URL != "/pl/p/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")
got, err = reg.Resolve(ctx, classes.MenuTypePost, id(post.ID), "/:category/: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 {
if want := "pl=/alfa/2026/03/04/witaj en=/en/alpha/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)
}
// journal-category: references are the tree; nesting returns the
// subtree, each child once with its first-locale URL.
refs, err = signpost.ReferenceOptions(ctx, reg, classes.MenuTypeCategory)
if err != nil || len(refs.Options) != 2 || refs.Options[0].Label != "Beta" || refs.Options[1].Label != "Alpha" || refs.Options[1].Depth != 1 {
t.Fatalf("category references = %+v err=%v", refs, err)
}
got, err = reg.Resolve(ctx, classes.MenuTypeCategory, id(beta.ID), "", true, "")
if err != nil || got == nil {
t.Fatalf("resolve journal-category: %+v %v", got, err)
}
if got.URL != "/pl/beta" || len(got.Items) != 1 || got.Items[0].URL != "/pl/alfa" || got.Items[0].Title != "Alpha" {
t.Fatalf("nested category = %+v", got)
}
if want := "pl=/pl/alfa en=/alpha"; altString(got.Items[0].Alternates) != want {
t.Fatalf("child alternates = %q, want %q", altString(got.Items[0].Alternates), want)
}
got, err = reg.Resolve(ctx, classes.MenuTypeCategory, id(beta.ID), "", false, "")
if err != nil || got == nil || len(got.Items) != 0 {
t.Fatalf("category without nesting = %+v %v", got, err)
}
// all-journal-categories: no URL, items by name, the host override of
// journal-category reaches them.
got, err = reg.Resolve(ctx, classes.MenuTypeAllCategories, "", "", false, "")
if err != nil || got == nil || got.URL != "" {
t.Fatalf("resolve all-journal-categories: %+v %v", got, err)
}
if want := "Alpha=/pl/alfa Beta=/pl/beta"; itemURLs(got.Items) != want {
t.Fatalf("all categories = %q, want %q", itemURLs(got.Items), want)
}
if want := "pl=/pl/alfa en=/alpha"; altString(got.Items[0].Alternates) != want {
t.Fatalf("all categories alternates = %q, want %q", altString(got.Items[0].Alternates), want)
}
// all-journal-posts: published only, by title, following the host
// override of journal-post; an item override reaches the children.
got, err = reg.Resolve(ctx, classes.MenuTypeAllPosts, "", "", false, "")
if err != nil || got == nil || got.URL != "" {
t.Fatalf("resolve all-journal-posts: %+v %v", got, err)
}
if want := "Hello=/pl/p/witaj Zed=/pl/p/zed"; itemURLs(got.Items) != want {
t.Fatalf("all posts = %q, want %q", itemURLs(got.Items), want)
}
if want := "pl=/pl/p/zed en=/p/zed"; altString(got.Items[1].Alternates) != want {
t.Fatalf("all posts alternates = %q, want %q", altString(got.Items[1].Alternates), want)
}
got, err = reg.Resolve(ctx, classes.MenuTypeAllPosts, "", "/x/:slug", false, "")
if err != nil || got == nil {
t.Fatalf("resolve all-journal-posts with item pattern: %+v %v", got, err)
}
if want := "pl=/pl/x/witaj en=/x/hello"; altString(got.Items[0].Alternates) != want {
t.Fatalf("item override alternates = %q, want %q", altString(got.Items[0].Alternates), want)
}
// category-journal-posts: the category and its descendants, published
// only, by title.
got, err = reg.Resolve(ctx, classes.MenuTypeCategoryPosts, id(beta.ID), "", false, "")
if err != nil || got == nil || got.URL != "" {
t.Fatalf("resolve category-journal-posts: %+v %v", got, err)
}
if want := "Hello=/pl/p/witaj Zed=/pl/p/zed"; itemURLs(got.Items) != want {
t.Fatalf("category posts = %q, want %q", itemURLs(got.Items), want)
}
got, err = reg.Resolve(ctx, classes.MenuTypeCategoryPosts, id(alpha.ID), "", false, "")
if err != nil || got == nil || itemURLs(got.Items) != "Hello=/pl/p/witaj" {
t.Fatalf("child category posts = %+v %v", got, err)
}
// Without an enabled locale the single alternate is x-default.
if err := gdb.Exec(`UPDATE golem15_translate_locales SET is_enabled = FALSE`).Error; err != nil {
t.Fatal(err)
}
got, err = reg.Resolve(ctx, classes.MenuTypePost, id(post.ID), "", false, "")
if err != nil || got == nil {
t.Fatalf("resolve without locales: %+v %v", got, err)
}
if want := "x-default=/p/hello"; altString(got.Alternates) != want || got.URL != "/p/hello" {
t.Fatalf("without locales = %q %q", altString(got.Alternates), got.URL)
}
}